diff --git a/apps/api/internal/clients/media_clients.go b/apps/api/internal/clients/media_clients.go index 9cdf19b..8b7c297 100644 --- a/apps/api/internal/clients/media_clients.go +++ b/apps/api/internal/clients/media_clients.go @@ -398,6 +398,36 @@ func (c MinimaxClient) runVoiceClone(ctx context.Context, request Request) (Resp }, nil } +func (c MinimaxClient) DeleteVoiceClone(ctx context.Context, request VoiceCloneDeleteRequest) (VoiceCloneDeleteResponse, error) { + startedAt := time.Now() + voiceID := strings.TrimSpace(request.VoiceID) + if voiceID == "" { + return VoiceCloneDeleteResponse{}, &ClientError{Code: "bad_request", Message: "voice_id is required", Retryable: false} + } + client := httpClient(request.HTTPClient, c.HTTPClient) + payload := map[string]any{ + "voice_type": "voice_cloning", + "voice_id": voiceID, + } + result, requestID, err := providerPostJSON(ctx, client, providerURL(request.Candidate.BaseURL, "/delete_voice"), payload, request.Candidate.Credentials, "bearer") + finishedAt := time.Now() + if err != nil { + return VoiceCloneDeleteResponse{}, annotateResponseError(err, requestID, startedAt, finishedAt) + } + if isProviderTaskFailure(providerTaskSpec{Name: "minimax"}, result) { + return VoiceCloneDeleteResponse{}, providerTaskFailure(providerTaskSpec{Name: "minimax"}, result, firstNonEmptyString(requestID, requestIDFromResult(result)), startedAt) + } + deletedVoiceID := firstNonEmptyString(valueAtPath(result, "voice_id"), voiceID) + return VoiceCloneDeleteResponse{ + VoiceID: deletedVoiceID, + RequestID: firstNonEmptyString(requestID, requestIDFromResult(result)), + Result: result, + StartedAt: startedAt, + FinishedAt: finishedAt, + DurationMS: responseDurationMS(startedAt, finishedAt), + }, nil +} + func (c MinimaxClient) minimaxVoiceCloneFileID(ctx context.Context, client *http.Client, request Request, body map[string]any, purpose string, fileIDKey string, sourceKeys ...string) (any, string, error) { if value := firstPresent(body[fileIDKey], nil); value != nil { return normalizeMinimaxFileID(value), "", nil diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 8e46d8c..18b2540 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -60,6 +60,25 @@ type Client interface { Run(ctx context.Context, request Request) (Response, error) } +type VoiceCloneDeleteRequest struct { + VoiceID string + Candidate store.RuntimeModelCandidate + HTTPClient *http.Client +} + +type VoiceCloneDeleteResponse struct { + VoiceID string + RequestID string + Result map[string]any + StartedAt time.Time + FinishedAt time.Time + DurationMS int64 +} + +type VoiceCloneDeleter interface { + DeleteVoiceClone(ctx context.Context, request VoiceCloneDeleteRequest) (VoiceCloneDeleteResponse, error) +} + type ClientError struct { Code string Message string diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 0ae9a29..373ef97 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -1306,7 +1306,7 @@ func scopeForTaskKind(kind string) string { func statusFromRunError(err error) int { switch { - case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable": + case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy": return http.StatusBadRequest case clients.ErrorCode(err) == "cloned_voice_not_found": return http.StatusNotFound diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index b57bbe2..88df9da 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -145,6 +145,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /api/v1/speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true))) mux.Handle("POST /api/v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true))) mux.Handle("GET /api/v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices))) + mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice))) mux.Handle("POST /api/v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile))) mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks))) mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask))) @@ -178,6 +179,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true))) mux.Handle("GET /voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices))) mux.Handle("GET /v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices))) + mux.Handle("DELETE /voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice))) + mux.Handle("DELETE /v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice))) mux.Handle("POST /v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile))) mux.Handle("POST /v1/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask))) diff --git a/apps/api/internal/httpapi/voice_clone_handlers.go b/apps/api/internal/httpapi/voice_clone_handlers.go index 72a63d8..11593bf 100644 --- a/apps/api/internal/httpapi/voice_clone_handlers.go +++ b/apps/api/internal/httpapi/voice_clone_handlers.go @@ -36,3 +36,41 @@ func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } + +// deleteClonedVoice godoc +// @Summary 删除当前用户克隆音色 +// @Description 先从上游供应商删除音色,成功后再将网关数据库中的音色记录标记为 deleted。 +// @Tags voice-clone +// @Produce json +// @Security BearerAuth +// @Param voiceID path string true "克隆音色记录 ID 或上游 voice_id" +// @Success 200 {object} map[string]any +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 502 {object} ErrorEnvelope +// @Router /api/v1/voice_clone/voices/{voiceID} [delete] +// @Router /v1/voice_clone/voices/{voiceID} [delete] +// @Router /voice_clone/voices/{voiceID} [delete] +func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + if !apiKeyScopeAllowed(user, "voice.clone") { + writeError(w, http.StatusForbidden, "api key scope does not allow this capability") + return + } + result, err := s.runner.DeleteClonedVoice(r.Context(), user, r.PathValue("voiceID")) + if err != nil { + writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), runErrorCode(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": result.Voice, + "upstream": result.Upstream, + "requestId": result.RequestID, + }) +} diff --git a/apps/api/internal/runner/voice_clone.go b/apps/api/internal/runner/voice_clone.go index 67f8224..2be1172 100644 --- a/apps/api/internal/runner/voice_clone.go +++ b/apps/api/internal/runner/voice_clone.go @@ -18,6 +18,12 @@ type clonedVoiceBinding struct { Explicit bool } +type DeletedClonedVoiceResult struct { + Voice store.ClonedVoice `json:"voice"` + Upstream map[string]any `json:"upstream,omitempty"` + RequestID string `json:"requestId,omitempty"` +} + func validateVoiceCloneRequest(body map[string]any) error { voiceID := firstNonEmptyString(stringFromMap(body, "voice_id"), stringFromMap(body, "voiceId")) if !validMiniMaxVoiceID(voiceID) { @@ -184,6 +190,62 @@ func (s *Service) touchClonedVoiceUsage(ctx context.Context, user *auth.User, bo _ = s.store.TouchClonedVoiceUsage(ctx, voice.ID) } +func (s *Service) DeleteClonedVoice(ctx context.Context, user *auth.User, rawID string) (DeletedClonedVoiceResult, error) { + id := strings.TrimSpace(rawID) + if id == "" { + return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "bad_request", Message: "voice id is required", StatusCode: 400, Retryable: false} + } + clonedVoiceID := "" + voiceID := id + if looksLikeUUID(id) { + clonedVoiceID = id + voiceID = "" + } + voice, found, err := s.store.FindClonedVoiceForUser(ctx, user, clonedVoiceID, voiceID) + if err != nil { + return DeletedClonedVoiceResult{}, err + } + if !found { + return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "cloned_voice_not_found", Message: "cloned voice not found", StatusCode: 404, Retryable: false} + } + candidate, ok, err := s.store.GetRuntimeModelCandidateForVoiceCloneDeletion(ctx, voice.PlatformModelID, voice.PlatformID) + if err != nil { + return DeletedClonedVoiceResult{}, err + } + if !ok { + return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "cloned_voice_platform_unavailable", Message: "cloned voice platform binding is unavailable", StatusCode: 400, Retryable: false} + } + requestHTTPClient, err := s.httpClientForCandidate(candidate, false) + if err != nil { + return DeletedClonedVoiceResult{}, err + } + deleter, ok := s.clientFor(candidate, false).(clients.VoiceCloneDeleter) + if !ok { + return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "unsupported_operation", Message: "voice clone deletion is not supported by this provider", StatusCode: 400, Retryable: false} + } + upstream, err := deleter.DeleteVoiceClone(ctx, clients.VoiceCloneDeleteRequest{ + VoiceID: voice.VoiceID, + Candidate: candidate, + HTTPClient: requestHTTPClient, + }) + if err != nil { + return DeletedClonedVoiceResult{}, err + } + deleted, found, err := s.store.DeleteClonedVoiceForUser(ctx, user, voice.ID, voice.VoiceID) + if err != nil { + return DeletedClonedVoiceResult{}, err + } + if !found { + deleted = voice + deleted.Status = "deleted" + } + return DeletedClonedVoiceResult{ + Voice: deleted, + Upstream: upstream.Result, + RequestID: upstream.RequestID, + }, nil +} + func firstAudioURLFromResult(result map[string]any) string { items, _ := result["data"].([]any) for _, raw := range items { diff --git a/apps/api/internal/store/candidates.go b/apps/api/internal/store/candidates.go index c1f42d2..c628244 100644 --- a/apps/api/internal/store/candidates.go +++ b/apps/api/internal/store/candidates.go @@ -308,6 +308,68 @@ ORDER BY effective_priority ASC, return items, nil } +func (s *Store) GetRuntimeModelCandidateForVoiceCloneDeletion(ctx context.Context, platformModelID string, platformID string) (RuntimeModelCandidate, bool, error) { + platformModelID = strings.TrimSpace(platformModelID) + platformID = strings.TrimSpace(platformID) + if platformModelID == "" && platformID == "" { + return RuntimeModelCandidate{}, false, nil + } + var item RuntimeModelCandidate + var credentials []byte + var platformConfig []byte + err := s.pool.QueryRow(ctx, ` +SELECT p.id::text, p.platform_key, p.name, p.provider, + COALESCE(NULLIF(p.config->>'specType', ''), NULLIF(cp.provider_type, ''), NULLIF(p.config->>'sourceSpecType', ''), p.provider) AS spec_type, + COALESCE(p.base_url, ''), p.auth_type, p.credentials, p.config, + COALESCE(m.id::text, ''), COALESCE(NULLIF(m.provider_model_name, ''), m.model_name, 'voice_clone'), + COALESCE(m.model_name, 'voice_clone'), COALESCE(m.model_alias, '') +FROM integration_platforms p +LEFT JOIN platform_models m + ON m.platform_id = p.id + AND ( + (NULLIF($1, '')::uuid IS NOT NULL AND m.id = NULLIF($1, '')::uuid) + OR ( + NULLIF($1, '') IS NULL + AND m.model_type @> jsonb_build_array('voice_clone'::text) + ) + ) +LEFT JOIN model_catalog_providers cp ON cp.provider_key = p.provider OR cp.provider_code = p.provider +WHERE p.deleted_at IS NULL + AND ( + (NULLIF($1, '')::uuid IS NOT NULL AND m.id = NULLIF($1, '')::uuid) + OR (NULLIF($2, '')::uuid IS NOT NULL AND p.id = NULLIF($2, '')::uuid) + ) +ORDER BY CASE WHEN NULLIF($1, '')::uuid IS NOT NULL AND m.id = NULLIF($1, '')::uuid THEN 0 ELSE 1 END, + m.created_at DESC +LIMIT 1`, platformModelID, platformID).Scan( + &item.PlatformID, + &item.PlatformKey, + &item.PlatformName, + &item.Provider, + &item.SpecType, + &item.BaseURL, + &item.AuthType, + &credentials, + &platformConfig, + &item.PlatformModelID, + &item.ProviderModelName, + &item.ModelName, + &item.ModelAlias, + ) + if err != nil { + if IsNotFound(err) { + return RuntimeModelCandidate{}, false, nil + } + return RuntimeModelCandidate{}, false, err + } + item.Credentials = decodeObject(credentials) + item.PlatformConfig = decodeObject(platformConfig) + item.ModelType = "voice_clone" + item.ClientID = item.PlatformKey + ":voice_clone:" + firstNonEmpty(item.ProviderModelName, item.ModelName) + item.QueueKey = item.ClientID + return item, true, nil +} + type runtimeCandidateLoadInput struct { Policy map[string]any ConcurrentActive float64 diff --git a/apps/api/internal/store/cloned_voices.go b/apps/api/internal/store/cloned_voices.go index d825f9e..92564bd 100644 --- a/apps/api/internal/store/cloned_voices.go +++ b/apps/api/internal/store/cloned_voices.go @@ -133,6 +133,7 @@ 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 ) @@ -140,6 +141,8 @@ WHERE ( NULLIF($2, '') IS NOT NULL AND v.user_id = $2 ) +) + AND v.status <> 'deleted' ORDER BY v.created_at DESC`, gatewayUserID, userID) if err != nil { return nil, err @@ -181,6 +184,7 @@ WHERE ( (NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid) OR (NULLIF($4, '') IS NOT NULL AND v.voice_id = $4) ) + AND v.status NOT IN ('deleted', 'failed') 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)) @@ -193,6 +197,46 @@ LIMIT 1`, gatewayUserID, userID, clonedVoiceID, voiceID)) return item, true, nil } +func (s *Store) DeleteClonedVoiceForUser(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, ` +WITH updated AS ( + UPDATE gateway_cloned_voices v + SET status = 'deleted', updated_at = now() + 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) + ) + AND v.status <> 'deleted' + RETURNING * +) +SELECT `+clonedVoiceColumns+` +FROM updated v +LEFT JOIN integration_platforms p ON p.id = v.platform_id`, 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