Files
story/internal/httpapi/conversation_handlers.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

597 lines
28 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.ErrOnboardingState):
WriteError(writer, request, http.StatusConflict, "ONBOARDING_STATE_CONFLICT", "绑定状态或首通许可已变化,请同步当前设备状态")
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.ErrHardwareSDKUpdateRequired):
WriteError(writer, request, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED", "火山硬件智能体SDK低于当前准入版本,请先完成升级")
case errors.Is(err, orchestration.ErrConversationAlreadyActive):
WriteError(writer, request, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE", "设备已有未结束通话")
case errors.Is(err, orchestration.ErrCallContractRequired):
WriteError(writer, request, http.StatusUpgradeRequired, "CALL_MEMORY_PROTOCOL_REQUIRED", "记忆已发生关键变更,请使用支持 CALL-MEMORY-1.1 的设备固件")
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,
})
}
}