Files
story/internal/mockprovider/server.go
T

432 lines
15 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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
}