122 lines
4.9 KiB
Go
122 lines
4.9 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"regexp"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/orchestration"
|
||
|
|
"data-terminal-cloud/internal/voiceprovider"
|
||
|
|
)
|
||
|
|
|
||
|
|
type ProviderCallbackProcessor interface {
|
||
|
|
Process(context.Context, voiceprovider.CallbackEvent, []byte) (orchestration.ProviderEventResult, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
func VoiceProviderCallbackHandler(secret []byte, maxSkew time.Duration, processor ProviderCallbackProcessor) http.Handler {
|
||
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||
|
|
rawBody, err := io.ReadAll(request.Body)
|
||
|
|
if err != nil {
|
||
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_CALLBACK", "供应商通知无法读取")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := voiceprovider.VerifyCallback(
|
||
|
|
secret, request.Header.Get(voiceprovider.HeaderCallbackTimestamp),
|
||
|
|
request.Header.Get(voiceprovider.HeaderCallbackSignature), rawBody, time.Now(), maxSkew,
|
||
|
|
); err != nil {
|
||
|
|
WriteError(writer, request, http.StatusUnauthorized, "PROVIDER_CALLBACK_AUTHENTICATION_FAILED", "供应商通知身份验证失败")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
var event voiceprovider.CallbackEvent
|
||
|
|
if err := decoder.Decode(&event); err != nil || ensureJSONEOF(decoder) != nil ||
|
||
|
|
event.Provider == "" || event.EventID == "" || event.TaskRef == "" || event.ConversationID == "" ||
|
||
|
|
!validProviderEventType(event.EventType) || !validProviderStatus(event.Status) || event.OccurredAt.IsZero() ||
|
||
|
|
len(event.EventID) > 255 || len(event.TaskRef) > 200 || !validProviderTurns(event) || !validProviderUsage(event) {
|
||
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_CALLBACK", "供应商通知字段不正确")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
result, err := processor.Process(request.Context(), event, rawBody)
|
||
|
|
if err != nil {
|
||
|
|
slog.ErrorContext(request.Context(), "provider callback processing failed", "event_type", event.EventType, "provider", event.Provider, "error", err)
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, orchestration.ErrProviderTaskNotFound):
|
||
|
|
WriteError(writer, request, http.StatusNotFound, "PROVIDER_TASK_NOT_FOUND", "供应商任务不存在")
|
||
|
|
case errors.Is(err, orchestration.ErrProviderEventConflict):
|
||
|
|
WriteError(writer, request, http.StatusConflict, "PROVIDER_EVENT_CONFLICT", "供应商事件编号对应的内容不一致")
|
||
|
|
default:
|
||
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "PROVIDER_CALLBACK_UNAVAILABLE", Message: "供应商通知暂时无法处理", Retryable: true})
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
WriteJSON(writer, request, http.StatusOK, result)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func validProviderEventType(value string) bool {
|
||
|
|
return value == "voice_chat.started" || value == "voice_chat.stopped" || value == "voice_chat.failed" || value == "voice_chat.transcript.final" || value == "voice_chat.usage"
|
||
|
|
}
|
||
|
|
|
||
|
|
func validProviderUsage(event voiceprovider.CallbackEvent) bool {
|
||
|
|
if event.EventType != "voice_chat.usage" {
|
||
|
|
return len(event.Usage) == 0
|
||
|
|
}
|
||
|
|
if event.Status != "FINAL" || len(event.Usage) == 0 || len(event.Usage) > 100 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
categories := map[string]bool{"RTC": true, "END_TO_END_VOICE": true, "ASR": true, "LLM": true, "TTS": true, "STORAGE": true, "POST_PROCESSING": true}
|
||
|
|
code := regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,79}$`)
|
||
|
|
unit := regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,39}$`)
|
||
|
|
quantity := regexp.MustCompile(`^(0|[1-9][0-9]{0,17})(\.[0-9]{1,6})?$`)
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
for _, metric := range event.Usage {
|
||
|
|
key := metric.UsageCategory + ":" + metric.MetricCode + ":" + metric.Unit
|
||
|
|
if !categories[metric.UsageCategory] || !code.MatchString(metric.MetricCode) || !unit.MatchString(metric.Unit) || !quantity.MatchString(metric.Quantity) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if _, exists := seen[key]; exists {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
seen[key] = struct{}{}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func validProviderStatus(value string) bool {
|
||
|
|
return value == "ACTIVE" || value == "COMPLETED" || value == "FAILED" || value == "FINAL"
|
||
|
|
}
|
||
|
|
|
||
|
|
func validProviderTurns(event voiceprovider.CallbackEvent) bool {
|
||
|
|
if event.EventType != "voice_chat.transcript.final" {
|
||
|
|
return len(event.Turns) == 0
|
||
|
|
}
|
||
|
|
if event.Status != "FINAL" || len(event.Turns) == 0 || len(event.Turns) > 500 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
seenKeys := make(map[string]struct{}, len(event.Turns))
|
||
|
|
seenSequences := make(map[int64]struct{}, len(event.Turns))
|
||
|
|
for _, turn := range event.Turns {
|
||
|
|
if turn.TurnKey == "" || len(turn.TurnKey) > 255 || turn.SequenceNo <= 0 ||
|
||
|
|
(turn.Speaker != "subject" && turn.Speaker != "agent") || turn.StartAt.IsZero() ||
|
||
|
|
turn.EndAt.Before(turn.StartAt) || turn.FinalText == "" || len([]rune(turn.FinalText)) > 20000 || len(turn.ProviderRef) > 255 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if _, exists := seenKeys[turn.TurnKey]; exists {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if _, exists := seenSequences[turn.SequenceNo]; exists {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
seenKeys[turn.TurnKey] = struct{}{}
|
||
|
|
seenSequences[turn.SequenceNo] = struct{}{}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|