package mockprovider import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "sync" "time" "data-terminal-cloud/internal/voiceprovider" ) type Options struct { APIKey string CallbackURL string CallbackSecret string StartDelay time.Duration DuplicateCallbacks int } type task struct { Request voiceprovider.StartRequest Status string } type Server struct { options Options logger *slog.Logger client *http.Client mu sync.Mutex tasks map[string]*task available bool startCalls int stopCalls int now func() time.Time } func New(options Options, logger *slog.Logger) (*Server, error) { if len(options.APIKey) < 16 || options.CallbackURL == "" || len(options.CallbackSecret) < 32 || options.StartDelay < 0 || options.DuplicateCallbacks < 1 { return nil, errors.New("valid mock provider options are required") } if logger == nil { logger = slog.Default() } return &Server{options: options, logger: logger, client: &http.Client{Timeout: 5 * time.Second}, tasks: map[string]*task{}, available: true, now: time.Now}, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"status":"alive","service":"mock-provider"}`)) }) mux.HandleFunc("POST /mock/v1/voice-chats:start", s.start) mux.HandleFunc("POST /mock/v1/voice-chats:query", s.query) mux.HandleFunc("POST /mock/v1/voice-chats:stop", s.stop) mux.HandleFunc("GET /mock/v1/admin/state", s.adminState) mux.HandleFunc("POST /mock/v1/admin/availability", s.adminAvailability) mux.HandleFunc("POST /mock/v1/admin/reset", s.adminReset) mux.HandleFunc("POST /mock/v1/admin/replay", s.adminReplay) return mux } func (s *Server) query(writer http.ResponseWriter, request *http.Request) { if !s.authorized(request) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } var input voiceprovider.QueryRequest decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.ConversationID == "" { http.Error(writer, "invalid request", http.StatusBadRequest) return } s.mu.Lock() available := s.available existing := s.tasks[input.TaskRef] s.mu.Unlock() if !available { http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable) return } if existing == nil { http.Error(writer, "task not found", http.StatusNotFound) return } if existing.Request.ConversationID != input.ConversationID { http.Error(writer, "task reference conflict", http.StatusConflict) return } writer.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(writer).Encode(voiceprovider.QueryResponse{TaskRef: input.TaskRef, Status: existing.Status}) } func (s *Server) start(writer http.ResponseWriter, request *http.Request) { if !hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey)) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } s.mu.Lock() s.startCalls++ available := s.available s.mu.Unlock() if !available { http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable) return } decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20)) decoder.DisallowUnknownFields() var input voiceprovider.StartRequest if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.RoomID == "" || input.DeviceUserID == "" || input.AgentUserID == "" || input.ConversationID == "" || !supportedEngineMode(input.EngineMode) { http.Error(writer, "invalid request", http.StatusBadRequest) return } s.mu.Lock() existing, found := s.tasks[input.TaskRef] if found && existing.Request != input { s.mu.Unlock() http.Error(writer, "task reference conflict", http.StatusConflict) return } if !found { existing = &task{Request: input, Status: "STARTING"} s.tasks[input.TaskRef] = existing } status := existing.Status s.mu.Unlock() if !found { go s.completeStart(input) } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(writer).Encode(voiceprovider.StartResponse{TaskRef: input.TaskRef, Status: status}) } func supportedEngineMode(mode string) bool { return mode == "s2s_pure" || mode == "s2s_hybrid" || mode == "cascade" } func (s *Server) completeStart(input voiceprovider.StartRequest) { timer := time.NewTimer(s.options.StartDelay) defer timer.Stop() <-timer.C s.mu.Lock() if existing := s.tasks[input.TaskRef]; existing != nil { if existing.Status != "STARTING" { s.mu.Unlock() return } existing.Status = "ACTIVE" } s.mu.Unlock() digest := sha256.Sum256([]byte(input.TaskRef)) event := voiceprovider.CallbackEvent{ Provider: "mock", EventID: "mock-started-" + hex.EncodeToString(digest[:12]), TaskRef: input.TaskRef, ConversationID: input.ConversationID, EventType: "voice_chat.started", Status: "ACTIVE", OccurredAt: s.now().UTC(), } body, err := json.Marshal(event) if err != nil { return } for index := 0; index < s.options.DuplicateCallbacks; index++ { if err := s.sendCallback(body); err != nil { s.logger.Warn("mock provider callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err) } } } func (s *Server) stop(writer http.ResponseWriter, request *http.Request) { if !hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey)) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } s.mu.Lock() s.stopCalls++ available := s.available s.mu.Unlock() if !available { http.Error(writer, "provider temporarily unavailable", http.StatusServiceUnavailable) return } decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20)) decoder.DisallowUnknownFields() var input voiceprovider.StopRequest if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.ConversationID == "" || input.Reason == "" { http.Error(writer, "invalid request", http.StatusBadRequest) return } s.mu.Lock() existing, found := s.tasks[input.TaskRef] if !found { s.mu.Unlock() http.Error(writer, "task not found", http.StatusNotFound) return } if existing.Request.ConversationID != input.ConversationID { s.mu.Unlock() http.Error(writer, "task reference conflict", http.StatusConflict) return } shouldComplete := existing.Status != "STOPPING" && existing.Status != "COMPLETED" if shouldComplete { existing.Status = "STOPPING" } status := existing.Status s.mu.Unlock() if shouldComplete { go s.completeStop(existing.Request) } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(writer).Encode(voiceprovider.StopResponse{TaskRef: input.TaskRef, Status: status}) } func (s *Server) adminState(writer http.ResponseWriter, request *http.Request) { if !s.authorized(request) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } s.mu.Lock() state := struct { Available bool `json:"available"` StartCalls int `json:"start_calls"` StopCalls int `json:"stop_calls"` Tasks int `json:"tasks"` }{Available: s.available, StartCalls: s.startCalls, StopCalls: s.stopCalls, Tasks: len(s.tasks)} s.mu.Unlock() writer.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(writer).Encode(state) } func (s *Server) adminAvailability(writer http.ResponseWriter, request *http.Request) { if !s.authorized(request) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } var input struct { Available *bool `json:"available"` } decoder := json.NewDecoder(io.LimitReader(request.Body, 1024)) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || input.Available == nil { http.Error(writer, "invalid request", http.StatusBadRequest) return } s.mu.Lock() s.available = *input.Available s.mu.Unlock() writer.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(writer).Encode(map[string]bool{"available": *input.Available}) } func (s *Server) adminReset(writer http.ResponseWriter, request *http.Request) { if !s.authorized(request) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } s.mu.Lock() s.available = true s.startCalls = 0 s.stopCalls = 0 s.tasks = map[string]*task{} s.mu.Unlock() writer.WriteHeader(http.StatusNoContent) } func (s *Server) adminReplay(writer http.ResponseWriter, request *http.Request) { if !s.authorized(request) { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } var input struct { TaskRef string `json:"task_ref"` Sample string `json:"sample"` DuplicateCount int `json:"duplicate_count"` } decoder := json.NewDecoder(io.LimitReader(request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || input.TaskRef == "" || input.Sample == "" || input.DuplicateCount < 0 || input.DuplicateCount > 10 { http.Error(writer, "invalid request", http.StatusBadRequest) return } if input.DuplicateCount == 0 { input.DuplicateCount = 1 } s.mu.Lock() existing := s.tasks[input.TaskRef] s.mu.Unlock() if existing == nil { http.Error(writer, "task not found", http.StatusNotFound) return } event, err := s.sampleEvent(existing.Request, input.Sample) if err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } body, err := json.Marshal(event) if err != nil { http.Error(writer, "encode sample", http.StatusInternalServerError) return } for attempt := 0; attempt < input.DuplicateCount; attempt++ { if err := s.sendCallback(body); err != nil { http.Error(writer, "callback delivery failed", http.StatusBadGateway) return } } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(writer).Encode(map[string]any{"sample": input.Sample, "deliveries": input.DuplicateCount, "event_id": event.EventID}) } func (s *Server) sampleEvent(input voiceprovider.StartRequest, sample string) (voiceprovider.CallbackEvent, error) { now := s.now().UTC() digest := sha256.Sum256([]byte("sample:" + sample + ":" + input.TaskRef)) event := voiceprovider.CallbackEvent{ Provider: "mock", EventID: "mock-sample-" + sample + "-" + hex.EncodeToString(digest[:8]), TaskRef: input.TaskRef, ConversationID: input.ConversationID, OccurredAt: now, } switch sample { case "started": event.EventType, event.Status = "voice_chat.started", "ACTIVE" case "final_transcript": event.EventType, event.Status = "voice_chat.transcript.final", "FINAL" event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-subject-101", SequenceNo: 101, Speaker: "subject", StartAt: now.Add(-3 * time.Second), EndAt: now.Add(-2 * time.Second), FinalText: "这是脱敏的长者最终字幕样例。", ProviderRef: "sample-subject-101"}} case "agent_answering": event.EventType, event.Status = "voice_chat.transcript.final", "FINAL" event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-agent-102", SequenceNo: 102, Speaker: "agent", StartAt: now.Add(-2 * time.Second), EndAt: now.Add(-time.Second), FinalText: "这是智能体开始回答的脱敏样例。", ProviderRef: "sample-agent-102"}} case "interrupted": event.EventType, event.Status = "voice_chat.transcript.final", "FINAL" event.Turns = []voiceprovider.FinalTurn{{TurnKey: "sample-agent-103", SequenceNo: 103, Speaker: "agent", StartAt: now.Add(-time.Second), EndAt: now, FinalText: "这是被打断的智能体回答样例。", Interrupted: true, ProviderRef: "sample-agent-103"}} case "usage": event.EventType, event.Status = "voice_chat.usage", "FINAL" event.Usage = []voiceprovider.UsageMetric{{UsageCategory: "RTC", MetricCode: "rtc.minutes", Quantity: "1", Unit: "minutes"}} case "error": event.EventType, event.Status = "voice_chat.failed", "FAILED" case "stopped": event.EventType, event.Status = "voice_chat.stopped", "COMPLETED" default: return voiceprovider.CallbackEvent{}, errors.New("unknown sample") } return event, nil } func (s *Server) authorized(request *http.Request) bool { return hmac.Equal([]byte(request.Header.Get("X-Mock-Provider-Key")), []byte(s.options.APIKey)) } func (s *Server) completeStop(input voiceprovider.StartRequest) { timer := time.NewTimer(s.options.StartDelay) defer timer.Stop() <-timer.C s.mu.Lock() if existing := s.tasks[input.TaskRef]; existing != nil { existing.Status = "COMPLETED" } s.mu.Unlock() s.sendFinalTranscript(input) digest := sha256.Sum256([]byte("stop:" + input.TaskRef)) event := voiceprovider.CallbackEvent{ Provider: "mock", EventID: "mock-stopped-" + hex.EncodeToString(digest[:12]), TaskRef: input.TaskRef, ConversationID: input.ConversationID, EventType: "voice_chat.stopped", Status: "COMPLETED", OccurredAt: s.now().UTC(), } body, err := json.Marshal(event) if err != nil { return } for index := 0; index < s.options.DuplicateCallbacks; index++ { if err := s.sendCallback(body); err != nil { s.logger.Warn("mock provider stop callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err) } } } func (s *Server) sendFinalTranscript(input voiceprovider.StartRequest) { base := s.now().UTC().Add(-20 * time.Second) digest := sha256.Sum256([]byte("transcript:" + input.TaskRef)) event := voiceprovider.CallbackEvent{ Provider: "mock", EventID: "mock-transcript-" + hex.EncodeToString(digest[:12]), TaskRef: input.TaskRef, ConversationID: input.ConversationID, EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(), Turns: []voiceprovider.FinalTurn{ {TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常在家乡河边散步,春天的柳树特别好看。", ProviderRef: "mock-turn-1"}, {TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"}, }, } body, err := json.Marshal(event) if err != nil { return } for index := 0; index < s.options.DuplicateCallbacks; index++ { if err := s.sendCallback(body); err != nil { s.logger.Warn("mock provider transcript callback failed", "task_ref", input.TaskRef, "attempt", index+1, "error", err) } } } func (s *Server) sendCallback(body []byte) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.options.CallbackURL, bytes.NewReader(body)) if err != nil { return err } timestamp := s.now().UTC().Format(time.RFC3339) request.Header.Set("Content-Type", "application/json") request.Header.Set(voiceprovider.HeaderCallbackTimestamp, timestamp) request.Header.Set(voiceprovider.HeaderCallbackSignature, voiceprovider.SignCallback([]byte(s.options.CallbackSecret), timestamp, body)) response, err := s.client.Do(request) if err != nil { return err } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20)) if response.StatusCode < 200 || response.StatusCode >= 300 { return errors.New("callback endpoint rejected event") } return nil }