591 lines
27 KiB
Go
591 lines
27 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"data-terminal-cloud/internal/conversation"
|
|
"data-terminal-cloud/internal/deviceauth"
|
|
"data-terminal-cloud/internal/idempotency"
|
|
"data-terminal-cloud/internal/orchestration"
|
|
)
|
|
|
|
type ConversationCreator interface {
|
|
Create(context.Context, deviceauth.Identity, string, string, []byte, orchestration.CreateInput) (orchestration.CreateResponse, bool, error)
|
|
}
|
|
|
|
type ConversationActivator interface {
|
|
Activate(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.ActivateInput) (orchestration.ActivateResponse, bool, error)
|
|
}
|
|
|
|
type DeviceConversationStateReader interface {
|
|
GetDeviceState(context.Context, deviceauth.Identity, string) (orchestration.DeviceConversationState, error)
|
|
}
|
|
|
|
type ConversationHeartbeater interface {
|
|
Heartbeat(context.Context, deviceauth.Identity, string, orchestration.HeartbeatInput) (orchestration.HeartbeatResponse, error)
|
|
}
|
|
|
|
type ConversationStopper interface {
|
|
Stop(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.StopInput) (orchestration.StopResponse, bool, error)
|
|
}
|
|
|
|
type ConversationTranscriptSubmitter interface {
|
|
Submit(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.TranscriptBatchInput) (orchestration.TranscriptBatchResponse, bool, error)
|
|
}
|
|
|
|
func CreateConversationHandler(service ConversationCreator) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "缺少有效的重复请求识别码",
|
|
FieldErrors: []FieldError{{Field: "Idempotency-Key", Message: "长度必须为16至128字符"}},
|
|
})
|
|
return
|
|
}
|
|
rawBody, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
|
decoder.DisallowUnknownFields()
|
|
var input orchestration.CreateInput
|
|
if err := decoder.Decode(&input); err != nil {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "创建通话参数不正确",
|
|
FieldErrors: []FieldError{{Field: "body", Message: "必须是符合契约的JSON对象"}},
|
|
})
|
|
return
|
|
}
|
|
if err := ensureJSONEOF(decoder); err != nil {
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文只能包含一个JSON对象")
|
|
return
|
|
}
|
|
if fieldErrors := validateCreateConversation(input); len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "创建通话参数不正确", FieldErrors: fieldErrors,
|
|
})
|
|
return
|
|
}
|
|
response, replayed, err := service.Create(request.Context(), identity, idempotencyKey, RequestID(request.Context()), rawBody, input)
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
if err != nil {
|
|
writeCreateConversationError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusCreated, response)
|
|
})
|
|
}
|
|
|
|
func ActivateConversationHandler(service ConversationActivator) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "缺少有效的重复请求识别码",
|
|
FieldErrors: []FieldError{{Field: "Idempotency-Key", Message: "长度必须为16至128字符"}},
|
|
})
|
|
return
|
|
}
|
|
rawBody, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
|
decoder.DisallowUnknownFields()
|
|
var input orchestration.ActivateInput
|
|
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "确认入房参数不正确",
|
|
FieldErrors: []FieldError{{Field: "body", Message: "必须是符合契约的JSON对象"}},
|
|
})
|
|
return
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.JoinedAt.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "joined_at", Message: "必须是RFC3339时间"})
|
|
}
|
|
if input.RoomID == "" || len(input.RoomID) > 200 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "room_id", Message: "不能为空且最长200字符"})
|
|
}
|
|
if input.RTCUserID == "" || len(input.RTCUserID) > 200 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "rtc_user_id", Message: "不能为空且最长200字符"})
|
|
}
|
|
if input.RTCJoinResult != "success" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "rtc_join_result", Message: "只接受success"})
|
|
}
|
|
if input.ConfigRevision < 1 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "config_revision", Message: "必须大于0"})
|
|
}
|
|
if len(input.ConnectionReference) > 255 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "connection_reference", Message: "最长255字符"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{Code: "VALIDATION_FAILED", Message: "确认入房参数不正确", FieldErrors: fieldErrors})
|
|
return
|
|
}
|
|
response, replayed, err := service.Activate(
|
|
request.Context(), identity, request.PathValue("id"), idempotencyKey,
|
|
RequestID(request.Context()), rawBody, input,
|
|
)
|
|
if err != nil {
|
|
writeActivateConversationError(writer, request, err)
|
|
return
|
|
}
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
WriteJSON(writer, request, http.StatusAccepted, response)
|
|
})
|
|
}
|
|
|
|
func GetDeviceConversationStateHandler(service DeviceConversationStateReader) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
conversationID := request.PathValue("id")
|
|
if !validUUID(conversationID) {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "通话编号不正确",
|
|
FieldErrors: []FieldError{{Field: "id", Message: "必须是UUID"}},
|
|
})
|
|
return
|
|
}
|
|
state, err := service.GetDeviceState(request.Context(), identity, conversationID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, orchestration.ErrConversationNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在或不属于当前设备")
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "CONVERSATION_STATE_UNAVAILABLE", Message: "暂时无法读取通话状态", Retryable: true})
|
|
}
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, state)
|
|
})
|
|
}
|
|
|
|
func HeartbeatConversationHandler(service ConversationHeartbeater) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(request.Body)
|
|
decoder.DisallowUnknownFields()
|
|
var input orchestration.HeartbeatInput
|
|
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "通话心跳参数不正确",
|
|
FieldErrors: []FieldError{{Field: "body", Message: "必须是符合契约的JSON对象"}},
|
|
})
|
|
return
|
|
}
|
|
if fieldErrors := validateHeartbeat(input); len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "通话心跳参数不正确", FieldErrors: fieldErrors,
|
|
})
|
|
return
|
|
}
|
|
response, err := service.Heartbeat(request.Context(), identity, request.PathValue("id"), input)
|
|
if err != nil {
|
|
writeHeartbeatError(writer, request, err)
|
|
return
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, response)
|
|
})
|
|
}
|
|
|
|
func StopConversationHandler(service ConversationStopper) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "缺少有效的重复请求识别码",
|
|
FieldErrors: []FieldError{{Field: "Idempotency-Key", Message: "长度必须为16至128字符"}},
|
|
})
|
|
return
|
|
}
|
|
rawBody, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
|
decoder.DisallowUnknownFields()
|
|
var input orchestration.StopInput
|
|
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "停止通话参数不正确",
|
|
FieldErrors: []FieldError{{Field: "body", Message: "必须是符合契约的JSON对象"}},
|
|
})
|
|
return
|
|
}
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.Reason == "" || len(input.Reason) > 64 || !isSafeErrorCode(input.Reason) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "reason", Message: "必须是最长64字符的原因代码"})
|
|
}
|
|
if input.DeviceTime.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "device_time", Message: "必须是RFC3339时间"})
|
|
}
|
|
if input.LastHeartbeatSequence < 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "last_heartbeat_sequence", Message: "不能小于0"})
|
|
}
|
|
if input.Recording.NearSegments < 0 || input.Recording.FarSegments < 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "recording", Message: "分片数量不能为负数"})
|
|
}
|
|
if len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "停止通话参数不正确", FieldErrors: fieldErrors,
|
|
})
|
|
return
|
|
}
|
|
response, replayed, err := service.Stop(
|
|
request.Context(), identity, request.PathValue("id"), idempotencyKey,
|
|
RequestID(request.Context()), rawBody, input,
|
|
)
|
|
if err != nil {
|
|
writeStopConversationError(writer, request, err)
|
|
return
|
|
}
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
WriteJSON(writer, request, http.StatusAccepted, response)
|
|
})
|
|
}
|
|
|
|
func SubmitConversationTurnsHandler(service ConversationTranscriptSubmitter) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
identity, ok := deviceauth.FromContext(request.Context())
|
|
if !ok {
|
|
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
|
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "缺少有效的重复请求识别码",
|
|
FieldErrors: []FieldError{{Field: "Idempotency-Key", Message: "长度必须为16至128字符"}},
|
|
})
|
|
return
|
|
}
|
|
rawBody, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
|
decoder.DisallowUnknownFields()
|
|
var input orchestration.TranscriptBatchInput
|
|
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "终稿补传参数不正确",
|
|
FieldErrors: []FieldError{{Field: "body", Message: "必须是符合契约的JSON对象"}},
|
|
})
|
|
return
|
|
}
|
|
if fieldErrors := validateTranscriptBatch(input); len(fieldErrors) > 0 {
|
|
WriteAPIError(writer, request, http.StatusBadRequest, Error{
|
|
Code: "VALIDATION_FAILED", Message: "终稿补传参数不正确", FieldErrors: fieldErrors,
|
|
})
|
|
return
|
|
}
|
|
response, replayed, err := service.Submit(
|
|
request.Context(), identity, request.PathValue("id"), idempotencyKey,
|
|
RequestID(request.Context()), rawBody, input,
|
|
)
|
|
if err != nil {
|
|
writeTranscriptBatchError(writer, request, err)
|
|
return
|
|
}
|
|
if replayed {
|
|
writer.Header().Set("Idempotency-Replayed", "true")
|
|
}
|
|
WriteJSON(writer, request, http.StatusOK, response)
|
|
})
|
|
}
|
|
|
|
func validateTranscriptBatch(input orchestration.TranscriptBatchInput) []FieldError {
|
|
fieldErrors := make([]FieldError, 0)
|
|
if len(input.Turns) < 1 || len(input.Turns) > 500 {
|
|
return append(fieldErrors, FieldError{Field: "turns", Message: "必须包含1至500条终稿"})
|
|
}
|
|
turnKeys := make(map[string]struct{}, len(input.Turns))
|
|
sequenceNumbers := make(map[int64]struct{}, len(input.Turns))
|
|
for index, turn := range input.Turns {
|
|
prefix := "turns[" + fmt.Sprint(index) + "]"
|
|
turnKey := strings.TrimSpace(turn.TurnKey)
|
|
if turnKey == "" || utf8.RuneCountInString(turnKey) > 255 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".turn_key", Message: "不能为空且最长255字符"})
|
|
} else if _, exists := turnKeys[turnKey]; exists {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".turn_key", Message: "请求内不能重复"})
|
|
} else {
|
|
turnKeys[turnKey] = struct{}{}
|
|
}
|
|
if turn.SequenceNo < 1 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".sequence_no", Message: "必须大于0"})
|
|
} else if _, exists := sequenceNumbers[turn.SequenceNo]; exists {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".sequence_no", Message: "请求内不能重复"})
|
|
} else {
|
|
sequenceNumbers[turn.SequenceNo] = struct{}{}
|
|
}
|
|
if turn.Speaker != "subject" && turn.Speaker != "agent" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".speaker", Message: "只允许subject或agent"})
|
|
}
|
|
if turn.StartAt.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".start_at", Message: "必须是RFC3339时间"})
|
|
}
|
|
if turn.EndAt.IsZero() || (!turn.StartAt.IsZero() && turn.EndAt.Before(turn.StartAt)) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".end_at", Message: "必须是不早于start_at的RFC3339时间"})
|
|
}
|
|
finalText := strings.TrimSpace(turn.FinalText)
|
|
if finalText == "" || utf8.RuneCountInString(finalText) > 20000 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".final_text", Message: "不能为空且最长20000字符"})
|
|
}
|
|
}
|
|
return fieldErrors
|
|
}
|
|
|
|
func validateHeartbeat(input orchestration.HeartbeatInput) []FieldError {
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.SequenceNo < 1 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "sequence_no", Message: "必须大于0"})
|
|
}
|
|
if input.DeviceTime.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "device_time", Message: "必须是RFC3339时间"})
|
|
}
|
|
if input.MonotonicMS == nil || *input.MonotonicMS < 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "monotonic_ms", Message: "必须为非负整数"})
|
|
}
|
|
if !conversation.IsKnown(input.ConversationStatus) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "conversation_status", Message: "不是有效的通话状态"})
|
|
}
|
|
if strings.TrimSpace(input.Network.Type) == "" || len(input.Network.Type) > 40 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "network.type", Message: "不能为空且最长40字符"})
|
|
}
|
|
if strings.TrimSpace(input.FirmwareVersion) == "" || len(input.FirmwareVersion) > 80 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "firmware_version", Message: "不能为空且最长80字符"})
|
|
}
|
|
if input.ReportedConfigRevision < 1 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "reported_config_revision", Message: "必须大于0"})
|
|
}
|
|
if input.RecordingQueue.PendingSegments < 0 || input.RecordingQueue.FailedSegments < 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "recording_queue", Message: "分片数量不能为负数"})
|
|
}
|
|
if len(input.RecordingQueue.LastFailureCode) > 80 || !isSafeErrorCode(input.RecordingQueue.LastFailureCode) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "recording_queue.last_failure_code", Message: "只能是最长80字符的错误码"})
|
|
}
|
|
if input.RecordingQueue.LastFailureAt != nil && input.RecordingQueue.LastFailureAt.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "recording_queue.last_failure_at", Message: "必须是RFC3339时间"})
|
|
}
|
|
if input.RTCCredentialExpiresAt != nil && input.RTCCredentialExpiresAt.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "rtc_credential_expires_at", Message: "必须是RFC3339时间"})
|
|
}
|
|
if len(input.LastError) > 80 || !isSafeErrorCode(input.LastError) {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "last_error", Message: "只能是最长80字符的错误码"})
|
|
}
|
|
if input.CommandResults == nil || len(input.CommandResults) > 20 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "command_results", Message: "必须是最多20项的数组"})
|
|
}
|
|
return fieldErrors
|
|
}
|
|
|
|
func isSafeErrorCode(value string) bool {
|
|
for _, character := range value {
|
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
|
(character >= '0' && character <= '9') || strings.ContainsRune("_.:-", character) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateCreateConversation(input orchestration.CreateInput) []FieldError {
|
|
fieldErrors := make([]FieldError, 0)
|
|
if input.DeviceTime.IsZero() {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "device_time", Message: "必须是RFC3339时间"})
|
|
}
|
|
if input.MonotonicMS != nil && *input.MonotonicMS < 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "monotonic_ms", Message: "不能小于0"})
|
|
}
|
|
if !input.Capabilities.RTC {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "capabilities.rtc", Message: "设备必须支持RTC"})
|
|
}
|
|
if input.Capabilities.UploadResume == nil {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "capabilities.upload_resume", Message: "不能为空"})
|
|
}
|
|
if len(input.Capabilities.AudioTracks) == 0 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "capabilities.audio_tracks", Message: "至少包含一个轨道"})
|
|
}
|
|
for _, track := range input.Capabilities.AudioTracks {
|
|
if track != "near" && track != "far" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "capabilities.audio_tracks", Message: "只允许near或far"})
|
|
break
|
|
}
|
|
}
|
|
if strings.TrimSpace(input.Network.Type) == "" || len(input.Network.Type) > 40 {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "network.type", Message: "不能为空且最长40字符"})
|
|
}
|
|
if input.RequestedGoal != "open_conversation" {
|
|
fieldErrors = append(fieldErrors, FieldError{Field: "requested_goal", Message: "首期只允许open_conversation"})
|
|
}
|
|
return fieldErrors
|
|
}
|
|
|
|
func ensureJSONEOF(decoder *json.Decoder) error {
|
|
var extra any
|
|
err := decoder.Decode(&extra)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return errors.New("additional JSON value")
|
|
}
|
|
|
|
func writeCreateConversationError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
var creationFailure *orchestration.CreateFailureError
|
|
switch {
|
|
case errors.As(err, &creationFailure):
|
|
WriteAPIError(writer, request, http.StatusUnprocessableEntity, Error{
|
|
Code: "AGENT_CONTEXT_PREPARATION_FAILED", Message: "背景资料准备失败,本次通话未创建语音房间",
|
|
Retryable: false, Details: map[string]any{
|
|
"conversation_id": creationFailure.ConversationID,
|
|
"status": "FAILED",
|
|
"failure_code": creationFailure.FailureCode,
|
|
},
|
|
})
|
|
case errors.Is(err, orchestration.ErrDeviceNotBound):
|
|
WriteError(writer, request, http.StatusConflict, "DEVICE_NOT_BOUND", "设备尚未绑定有效长者")
|
|
case errors.Is(err, orchestration.ErrConsentRequired):
|
|
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
|
|
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
|
|
WriteError(writer, request, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED", "设备固件低于当前通话最低版本,请先完成升级")
|
|
case errors.Is(err, orchestration.ErrConversationAlreadyActive):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE", "设备已有未结束通话")
|
|
case errors.Is(err, orchestration.ErrUsageQuotaExceeded):
|
|
WriteError(writer, request, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED", "客户组织或设备用量额度已达到上限,暂时不能创建新通话")
|
|
case errors.Is(err, orchestration.ErrRuntimeConfigUnavailable):
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "RUNTIME_CONFIG_UNAVAILABLE", Message: "当前没有可用于该设备的已发布语音路由配置", Retryable: true})
|
|
case errors.Is(err, idempotency.ErrKeyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "重复请求识别码已用于不同请求")
|
|
case errors.Is(err, idempotency.ErrInProgress):
|
|
WriteAPIError(writer, request, http.StatusConflict, Error{
|
|
Code: "REQUEST_IN_PROGRESS", Message: "相同请求正在处理中", Retryable: true,
|
|
})
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
|
|
Code: "CONVERSATION_CREATE_UNAVAILABLE", Message: "暂时无法创建通话", Retryable: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
func writeActivateConversationError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, orchestration.ErrConversationNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在")
|
|
case errors.Is(err, orchestration.ErrAllocationExpired):
|
|
WriteError(writer, request, http.StatusConflict, "ROOM_ALLOCATION_EXPIRED", "房间预留已经过期")
|
|
case errors.Is(err, orchestration.ErrRTCIdentityMismatch):
|
|
WriteError(writer, request, http.StatusConflict, "RTC_ALLOCATION_MISMATCH", "入房信息与云端分配不一致")
|
|
case errors.Is(err, orchestration.ErrConfigVersionConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONFIG_VERSION_CONFLICT", "设备配置版本与当前期望版本不一致")
|
|
case errors.Is(err, orchestration.ErrConversationStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_STATE_CONFLICT", "当前通话状态不允许确认入房")
|
|
case errors.Is(err, idempotency.ErrKeyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "重复请求识别码已用于不同请求")
|
|
case errors.Is(err, idempotency.ErrInProgress):
|
|
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "REQUEST_IN_PROGRESS", Message: "相同请求正在处理中", Retryable: true})
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "CONVERSATION_ACTIVATE_UNAVAILABLE", Message: "暂时无法确认设备入房", Retryable: true})
|
|
}
|
|
}
|
|
|
|
func writeHeartbeatError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, orchestration.ErrConversationNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在")
|
|
case errors.Is(err, orchestration.ErrConfigVersionConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONFIG_VERSION_CONFLICT", "设备配置版本高于云端期望版本")
|
|
case errors.Is(err, orchestration.ErrConversationStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_STATE_CONFLICT", "当前通话状态不接受心跳")
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
|
|
Code: "CONVERSATION_HEARTBEAT_UNAVAILABLE", Message: "暂时无法处理通话心跳", Retryable: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
func writeStopConversationError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, orchestration.ErrConversationNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在")
|
|
case errors.Is(err, orchestration.ErrConversationStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_STATE_CONFLICT", "当前通话状态不允许停止")
|
|
case errors.Is(err, idempotency.ErrKeyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "重复请求识别码已用于不同请求")
|
|
case errors.Is(err, idempotency.ErrInProgress):
|
|
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "REQUEST_IN_PROGRESS", Message: "相同请求正在处理中", Retryable: true})
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
|
|
Code: "CONVERSATION_STOP_UNAVAILABLE", Message: "暂时无法停止通话", Retryable: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
func writeTranscriptBatchError(writer http.ResponseWriter, request *http.Request, err error) {
|
|
var conflict *orchestration.TranscriptConflictError
|
|
switch {
|
|
case errors.Is(err, orchestration.ErrConversationNotFound):
|
|
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在")
|
|
case errors.Is(err, orchestration.ErrConversationStateConflict):
|
|
WriteError(writer, request, http.StatusConflict, "CONVERSATION_STATE_CONFLICT", "当前通话状态不允许补传终稿")
|
|
case errors.Is(err, orchestration.ErrTranscriptStorageNotAllowed):
|
|
WriteError(writer, request, http.StatusForbidden, "TRANSCRIPT_STORAGE_NOT_ALLOWED", "当前留存策略或授权不允许保存文字终稿")
|
|
case errors.As(err, &conflict):
|
|
WriteAPIError(writer, request, http.StatusConflict, Error{
|
|
Code: "TRANSCRIPT_TURN_CONFLICT", Message: "补传终稿与已保存终稿不一致",
|
|
Details: map[string]any{"conflicts": conflict.Conflicts},
|
|
})
|
|
case errors.Is(err, idempotency.ErrKeyConflict):
|
|
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "重复请求识别码已用于不同请求")
|
|
case errors.Is(err, idempotency.ErrInProgress):
|
|
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "REQUEST_IN_PROGRESS", Message: "相同请求正在处理中", Retryable: true})
|
|
default:
|
|
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
|
|
Code: "TRANSCRIPT_BATCH_UNAVAILABLE", Message: "暂时无法处理终稿补传", Retryable: true,
|
|
})
|
|
}
|
|
}
|