chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
package httpapi
import (
"errors"
"log/slog"
"net/http"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/idempotency"
)
func ListOpsAlertRoutingPoliciesHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, e := service.ListRoutingPolicies(r.Context(), p, tenant)
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateOpsAlertRoutingPolicyHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body alerts.RoutingInput
if !decodeWebJSON(w, r, &body, "告警路由参数不正确") {
return
}
item, created, e := service.CreateRoutingPolicy(r.Context(), p, tenant, key, RequestID(r.Context()), body)
if e != nil {
writeAlertError(w, r, e)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
WriteJSON(w, r, status, item)
})
}
func ListOpsAlertDeliveryTestsHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, e := service.ListDeliveryTests(r.Context(), p, tenant)
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateOpsAlertDeliveryTestHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenant, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body alerts.DeliveryTestInput
if !decodeWebJSON(w, r, &body, "告警接收器联通测试参数不正确") {
return
}
item, created, e := service.CreateDeliveryTest(r.Context(), p, tenant, r.PathValue("id"), key, RequestID(r.Context()), body)
if e != nil {
writeAlertError(w, r, e)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
WriteJSON(w, r, status, item)
})
}
func ListOpsAlertsHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, e := service.ListIncidents(r.Context(), p, tenant, r.URL.Query().Get("status"))
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func GetOpsAlertHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
item, e := service.GetIncident(r.Context(), p, tenant, r.PathValue("id"))
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
WriteJSON(w, r, http.StatusOK, item)
})
}
func TransitionOpsAlertHandler(service *alerts.Service, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
version, e := concurrency.ParseIfMatch(r)
if e != nil {
writeETagError(w, r, e)
return
}
var body struct {
Note string `json:"note"`
}
if !decodeWebJSON(w, r, &body, "告警处置参数不正确") {
return
}
item, e := service.TransitionIncident(r.Context(), p, tenant, r.PathValue("id"), RequestID(r.Context()), action, body.Note, version)
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
WriteJSON(w, r, http.StatusOK, item)
})
}
func ListOpsAlertSilencesHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, e := service.ListSilences(r.Context(), p, tenant)
if e != nil {
writeAlertError(w, r, e)
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateOpsAlertSilenceHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := webPrincipal(w, r)
if !ok {
return
}
tenant, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body alerts.SilenceInput
if !decodeWebJSON(w, r, &body, "告警静默参数不正确") {
return
}
item, created, e := service.CreateSilence(r.Context(), p, tenant, key, RequestID(r.Context()), body)
if e != nil {
writeAlertError(w, r, e)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
WriteJSON(w, r, status, item)
})
}
func RevokeOpsAlertSilenceHandler(service *alerts.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenant, ok := opsTenantHeader(w, r)
if !ok {
return
}
version, e := concurrency.ParseIfMatch(r)
if e != nil {
writeETagError(w, r, e)
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "静默撤销参数不正确") {
return
}
item, e := service.RevokeSilence(r.Context(), p, tenant, r.PathValue("id"), RequestID(r.Context()), body.Reason, version)
if e != nil {
writeAlertError(w, r, e)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
WriteJSON(w, r, http.StatusOK, item)
})
}
func writeAlertError(w http.ResponseWriter, r *http.Request, e error) {
switch {
case errors.Is(e, alerts.ErrInvalid):
writeValidation(w, r, "告警参数不正确", FieldError{Field: "alert", Message: "字段、状态、时间范围或引用不符合约束"})
case errors.Is(e, alerts.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "当前账号没有所需的内部运营权限")
case errors.Is(e, alerts.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "ALERT_NOT_FOUND", "告警、路由或静默记录不存在")
case errors.Is(e, alerts.ErrVersionConflict):
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "资源版本已变化,请重新读取后再操作")
case errors.Is(e, alerts.ErrStateConflict):
WriteError(w, r, http.StatusConflict, "ALERT_STATE_CONFLICT", "当前告警或静默状态不允许该操作")
case errors.Is(e, idempotency.ErrKeyConflict):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已被不同请求使用")
default:
slog.ErrorContext(r.Context(), "alert operation failed", "request_id", RequestID(r.Context()), "error", e)
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "告警处理失败")
}
}
@@ -0,0 +1,169 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestAlertHandlersWriteAndReadLifecycle(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := alerts.NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
tenantID := "10000000-0000-0000-0000-000000000010"
routeKey := "http-alert-route-" + uuid.NewString()
deliveryTestKey := "http-alert-delivery-test-" + uuid.NewString()
silenceKey := "http-alert-silence-" + uuid.NewString()
incidentID, incidentPublicID := uuid.NewString(), uuid.NewString()
createdRouteID := "00000000-0000-0000-0000-000000000000"
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'http-alert-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE action='alert.delivery_test.create' AND purpose='HTTP联通测试'`)
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{routeKey, deliveryTestKey, silenceKey})
_, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE event_type='alert.webhook_test_requested' AND payload->>'routing_policy_id'=$1`, createdRouteID)
_, _ = pool.Exec(ctx, `DELETE FROM alert_incident WHERE id=$1::uuid`, incidentID)
_, _ = pool.Exec(ctx, `DELETE FROM alert_silence WHERE reason='HTTP集成维护窗口'`)
_, _ = pool.Exec(ctx, `DELETE FROM alert_routing_policy WHERE category='BUSINESS' AND severity='LOW'`)
}()
request := authenticatedRequest(http.MethodPost, "/api/v1/ops/alert-routing-policies", `{}`, principal)
recorder := httptest.NewRecorder()
CreateOpsAlertRoutingPolicyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing ops headers status=%d body=%s", recorder.Code, recorder.Body.String())
}
routeBody := `{"category":"BUSINESS","severity":"LOW","receiver_group":"http-oncall","response_minutes":30,"escalation_after_minutes":60,"escalation_receiver_group":"http-command","runbook_ref":"runbook://http/alert","change_summary":"HTTP告警路由"}`
request = authenticatedRequest(http.MethodPost, "/api/v1/ops/alert-routing-policies", routeBody, principal)
request.Header.Set("X-Tenant-Id", tenantID)
request.Header.Set("Idempotency-Key", routeKey)
recorder = httptest.NewRecorder()
CreateOpsAlertRoutingPolicyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create route status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var routeResponse struct {
Data alerts.RoutingPolicy `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &routeResponse); err != nil {
t.Fatal(err)
}
createdRouteID = routeResponse.Data.ID
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/alert-routing-policies", "", principal)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
ListOpsAlertRoutingPoliciesHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("list routes status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/ops/alert-routing-policies/"+routeResponse.Data.ID+"/delivery-tests", `{"reason":"HTTP联通测试"}`, principal)
request.SetPathValue("id", routeResponse.Data.ID)
request.Header.Set("X-Tenant-Id", tenantID)
request.Header.Set("Idempotency-Key", deliveryTestKey)
recorder = httptest.NewRecorder()
CreateOpsAlertDeliveryTestHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || !strings.Contains(recorder.Body.String(), `"status":"PENDING"`) {
t.Fatalf("create delivery test status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/alert-delivery-tests", "", principal)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
ListOpsAlertDeliveryTestsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), routeResponse.Data.ID) {
t.Fatalf("list delivery tests status=%d body=%s", recorder.Code, recorder.Body.String())
}
startsAt, endsAt := time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(time.Hour)
silenceBody, _ := json.Marshal(map[string]any{"rule_code": "HTTP_INTEGRATION_ALERT", "matcher": map[string]any{"source": "http"}, "reason": "HTTP集成维护窗口", "starts_at": startsAt, "ends_at": endsAt})
request = authenticatedRequest(http.MethodPost, "/api/v1/ops/alert-silences", string(silenceBody), principal)
request.Header.Set("X-Tenant-Id", tenantID)
request.Header.Set("Idempotency-Key", silenceKey)
recorder = httptest.NewRecorder()
CreateOpsAlertSilenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("create silence status=%d body=%s", recorder.Code, recorder.Body.String())
}
var silenceResponse struct {
Data alerts.Silence `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &silenceResponse); err != nil {
t.Fatal(err)
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/alert-silences", "", principal)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
ListOpsAlertSilencesHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list silences status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/ops/alert-silences/"+silenceResponse.Data.ID+"/revoke", `{"reason":"维护结束"}`, principal)
request.SetPathValue("id", silenceResponse.Data.ID)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
RevokeOpsAlertSilenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing silence ETag status=%d body=%s", recorder.Code, recorder.Body.String())
}
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
RevokeOpsAlertSilenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("revoke silence status=%d body=%s", recorder.Code, recorder.Body.String())
}
var routeInternalID string
if err := pool.QueryRow(ctx, `SELECT id::text FROM alert_routing_policy WHERE public_id=$1::uuid`, routeResponse.Data.ID).Scan(&routeInternalID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO alert_incident(id,public_id,tenant_id,rule_code,category,severity,status,fingerprint,title,summary,routing_policy_id,first_detected_at,last_detected_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','HTTP_INTEGRATION_ALERT','BUSINESS','LOW','OPEN',$3,'HTTP告警','HTTP告警接口集成',$4::uuid,now(),now())`, incidentID, incidentPublicID, "http-alert-"+incidentPublicID, routeInternalID); err != nil {
t.Fatal(err)
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/alerts?status=OPEN", "", principal)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
ListOpsAlertsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list alerts status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/alerts/"+incidentPublicID, "", principal)
request.SetPathValue("id", incidentPublicID)
request.Header.Set("X-Tenant-Id", tenantID)
recorder = httptest.NewRecorder()
GetOpsAlertHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("get alert status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/ops/alerts/"+incidentPublicID+"/acknowledge", `{"note":"HTTP确认告警"}`, principal)
request.SetPathValue("id", incidentPublicID)
request.Header.Set("X-Tenant-Id", tenantID)
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
TransitionOpsAlertHandler(service, "ACKNOWLEDGE").ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) || !strings.Contains(recorder.Body.String(), "ACKNOWLEDGED") {
t.Fatalf("ack alert status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+55
View File
@@ -0,0 +1,55 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"data-terminal-cloud/internal/identity"
)
func BearerAuth(verifier identity.TokenVerifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
rawToken, ok := bearerToken(request.Header.Get("Authorization"))
if !ok {
writer.Header().Set("WWW-Authenticate", "Bearer")
WriteError(writer, request, http.StatusUnauthorized, "AUTHENTICATION_REQUIRED", "请先登录后再访问")
return
}
principal, err := verifier.Verify(request.Context(), rawToken)
if err != nil {
writer.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
WriteError(writer, request, http.StatusUnauthorized, "AUTHENTICATION_INVALID", "登录凭证无效或已经过期")
return
}
next.ServeHTTP(writer, request.WithContext(identity.WithPrincipal(request.Context(), principal)))
})
}
func CurrentUserHandler(repository *identity.Repository) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Cache-Control", "no-store")
principal, ok := identity.PrincipalFromContext(request.Context())
if !ok {
WriteError(writer, request, http.StatusUnauthorized, "AUTHENTICATION_REQUIRED", "请先登录后再访问")
return
}
currentUser, err := repository.CurrentUser(request.Context(), principal)
switch {
case errors.Is(err, identity.ErrAccountNotFound):
WriteError(writer, request, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号无权访问该资源")
case err != nil:
WriteError(writer, request, http.StatusInternalServerError, "INTERNAL_ERROR", "服务暂时不可用")
default:
WriteJSON(writer, request, http.StatusOK, currentUser)
}
})
}
func bearerToken(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
return "", false
}
return parts[1], true
}
+65
View File
@@ -0,0 +1,65 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestCurrentUserHandlerAuthenticationAccountAndRepositoryFailures(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
handler := CurrentUserHandler(identity.NewRepository(pool))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "AUTHENTICATION_REQUIRED") {
t.Fatalf("missing principal status=%d body=%s", recorder.Code, recorder.Body.String())
}
owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(identity.WithPrincipal(request.Context(), owner))
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), owner.Subject) || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("owner status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
unknown := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000009999"}
request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(identity.WithPrincipal(request.Context(), unknown))
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "RESOURCE_ACCESS_DENIED") {
t.Fatalf("unknown account status=%d body=%s", recorder.Code, recorder.Body.String())
}
closedPool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
closedPool.Close()
handler = CurrentUserHandler(identity.NewRepository(closedPool))
request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(identity.WithPrincipal(request.Context(), owner))
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "INTERNAL_ERROR") {
t.Fatalf("closed repository status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+76
View File
@@ -0,0 +1,76 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"data-terminal-cloud/internal/identity"
)
type fakeVerifier struct {
principal identity.Principal
err error
}
func (f fakeVerifier) Verify(context.Context, string) (identity.Principal, error) {
return f.principal, f.err
}
func TestBearerAuthRejectsMissingToken(t *testing.T) {
handler := BearerAuth(fakeVerifier{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("protected handler must not be called")
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
if recorder.Header().Get("WWW-Authenticate") != "Bearer" {
t.Fatal("WWW-Authenticate header is missing")
}
}
func TestBearerAuthRejectsInvalidToken(t *testing.T) {
handler := BearerAuth(fakeVerifier{err: errors.New("bad token")}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("protected handler must not be called")
}))
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request.Header.Set("Authorization", "Bearer invalid")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}
func TestBearerAuthStoresVerifiedPrincipal(t *testing.T) {
expected := identity.Principal{Issuer: "issuer", Subject: "subject"}
handler := BearerAuth(fakeVerifier{principal: expected}, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
actual, ok := identity.PrincipalFromContext(request.Context())
if !ok || actual != expected {
t.Fatalf("principal = %#v, %t, want %#v", actual, ok, expected)
}
writer.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request.Header.Set("Authorization", "Bearer valid")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
}
}
func TestBearerTokenParsing(t *testing.T) {
if token, ok := bearerToken("bearer abc.def"); !ok || token != "abc.def" {
t.Fatalf("token = %q, ok = %t", token, ok)
}
for _, header := range []string{"", "Basic abc", "Bearer", "Bearer one two"} {
if _, ok := bearerToken(header); ok {
t.Fatalf("header %q should be rejected", header)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package httpapi
import (
"errors"
"net/http"
"time"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/idempotency"
)
func CreateOpsPriceBookHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body billing.CreatePriceBookInput
if !decodeWebJSON(w, r, &body, "费用单价版本参数不正确") {
return
}
body.TenantID = tenantID
item, created, err := service.CreatePriceBook(r.Context(), principal, key, RequestID(r.Context()), body)
if err != nil {
writeBillingError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsPriceBooksHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListPriceBooks(r.Context(), principal, tenantID)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func PublishOpsPriceBookHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "费用版本发布参数不正确") {
return
}
item, err := service.PublishPriceBook(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), body.Reason, expected)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func CreateOpsQuotaPolicyHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body billing.QuotaInput
if !decodeWebJSON(w, r, &body, "用量额度策略参数不正确") {
return
}
item, created, err := service.CreateQuotaPolicy(r.Context(), principal, tenantID, key, RequestID(r.Context()), body)
if err != nil {
writeBillingError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsQuotaPoliciesHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListQuotaPolicies(r.Context(), principal, tenantID)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func GetOpsCostSummaryHandler(service *billing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
from, fromErr := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
to, toErr := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
if fromErr != nil || toErr != nil {
writeValidation(w, r, "费用汇总时间范围不正确", FieldError{Field: "from/to", Message: "必须是RFC3339时间且范围不超过366天"})
return
}
item, err := service.CostSummary(r.Context(), principal, tenantID, from, to)
if err != nil {
writeBillingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func writeBillingError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, billing.ErrInvalid):
writeValidation(w, r, "用量、费用或额度参数不正确", FieldError{Field: "billing", Message: "字段、单位、时区、金额或范围不符合约束"})
case errors.Is(err, billing.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "当前账号没有所需的内部运营权限")
case errors.Is(err, billing.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "费用或额度资源不存在")
case errors.Is(err, billing.ErrVersionConflict):
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "资源版本已变化,请重新读取后再操作")
case errors.Is(err, billing.ErrSelfApproval):
WriteError(w, r, http.StatusConflict, "SELF_APPROVAL_FORBIDDEN", "费用版本创建人不能审批自己的变更")
case errors.Is(err, billing.ErrStateConflict):
WriteError(w, r, http.StatusConflict, "BILLING_STATE_CONFLICT", "当前状态不允许执行该操作")
case errors.Is(err, idempotency.ErrKeyConflict):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已被不同请求使用")
case errors.Is(err, idempotency.ErrInProgress):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS", "相同请求正在处理中")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "用量费用处理失败")
}
}
@@ -0,0 +1,131 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestBillingHandlersPriceQuotaAndSummary(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := billing.NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
tenantID := "10000000-0000-0000-0000-000000000010"
priceKey, quotaKey := "http-billing-price-"+uuid.NewString(), "http-billing-quota-"+uuid.NewString()
sourceRef := "integration://http-billing/" + uuid.NewString()
changePrefix := "HTTP费用集成-" + uuid.NewString()
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'http-billing-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{priceKey, quotaKey})
_, _ = pool.Exec(ctx, `DELETE FROM usage_quota_policy WHERE change_summary LIKE $1`, changePrefix+"%")
_, _ = pool.Exec(ctx, `DELETE FROM billing_price_book WHERE source_ref=$1`, sourceRef)
}()
setHeaders := func(request *http.Request, key string) {
request.Header.Set("X-Tenant-Id", tenantID)
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
}
withID := func(request *http.Request, id string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), requestIDKey, id))
}
priceBody, _ := json.Marshal(map[string]any{"provider": "mock-http", "currency": "CNY", "source_ref": sourceRef, "change_summary": changePrefix + "价格", "effective_from": time.Now().UTC().Add(-time.Hour), "rates": []map[string]any{{"usage_category": "llm", "metric_code": "input_tokens", "unit": "token", "unit_price_micros": 2}}})
request := withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books", string(priceBody), admin), "http-billing-price-create")
setHeaders(request, priceKey)
recorder := httptest.NewRecorder()
CreateOpsPriceBookHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create price status=%d body=%s", recorder.Code, recorder.Body.String())
}
var priceResponse struct {
Data billing.PriceBook `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &priceResponse); err != nil {
t.Fatal(err)
}
price := priceResponse.Data
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/price-books", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsPriceBooksHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), price.ID) {
t.Fatalf("list prices status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books/"+price.ID+"/publish", `{"reason":"本人不得发布"}`, admin), "http-billing-self-publish")
request.SetPathValue("id", price.ID)
setHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
PublishOpsPriceBookHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "SELF_APPROVAL_FORBIDDEN") {
t.Fatalf("self publish status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books/"+price.ID+"/publish", `{"reason":"另一审批人复核发布"}`, approver), "http-billing-price-publish")
request.SetPathValue("id", price.ID)
setHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
PublishOpsPriceBookHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("publish price status=%d body=%s", recorder.Code, recorder.Body.String())
}
quotaBody, _ := json.Marshal(map[string]any{"scope_type": "TENANT", "billing_timezone": "Asia/Shanghai", "currency": "CNY", "daily_limit_micros": 10000000, "monthly_limit_micros": 100000000, "max_concurrent_calls": 10, "max_call_duration_sec": 3600, "in_progress_action": "COMPLETE_CURRENT_TURN", "change_summary": changePrefix + "额度"})
request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/quota-policies", string(quotaBody), approver), "http-billing-quota-create")
setHeaders(request, quotaKey)
recorder = httptest.NewRecorder()
CreateOpsQuotaPolicyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create quota status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/quota-policies", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsQuotaPoliciesHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), changePrefix) {
t.Fatalf("list quotas status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/cost-summary?from=bad&to=bad", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
GetOpsCostSummaryHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid summary range status=%d body=%s", recorder.Code, recorder.Body.String())
}
from, to := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/cost-summary?from="+url.QueryEscape(from)+"&to="+url.QueryEscape(to), "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
GetOpsCostSummaryHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("cost summary status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
@@ -0,0 +1,87 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/opsconfig"
)
func TestAlertBillingAndOpsConfigErrorsHaveStableMappings(t *testing.T) {
type errorWriter func(http.ResponseWriter, *http.Request, error)
tests := []struct {
name string
writer errorWriter
err error
status int
code string
}{
{"alert invalid", writeAlertError, alerts.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{"alert denied", writeAlertError, alerts.ErrAccessDenied, http.StatusForbidden, "OPS_ACCESS_DENIED"},
{"alert missing", writeAlertError, alerts.ErrNotFound, http.StatusNotFound, "ALERT_NOT_FOUND"},
{"alert version", writeAlertError, alerts.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"},
{"alert state", writeAlertError, alerts.ErrStateConflict, http.StatusConflict, "ALERT_STATE_CONFLICT"},
{"alert key", writeAlertError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{"alert dependency", writeAlertError, errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
{"billing invalid", writeBillingError, billing.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{"billing denied", writeBillingError, billing.ErrAccessDenied, http.StatusForbidden, "OPS_ACCESS_DENIED"},
{"billing missing", writeBillingError, billing.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{"billing version", writeBillingError, billing.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"},
{"billing self approval", writeBillingError, billing.ErrSelfApproval, http.StatusConflict, "SELF_APPROVAL_FORBIDDEN"},
{"billing state", writeBillingError, billing.ErrStateConflict, http.StatusConflict, "BILLING_STATE_CONFLICT"},
{"billing key", writeBillingError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{"billing in progress", writeBillingError, idempotency.ErrInProgress, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS"},
{"billing dependency", writeBillingError, errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
{"config invalid", writeOpsConfigError, opsconfig.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{"config denied", writeOpsConfigError, opsconfig.ErrAccessDenied, http.StatusForbidden, "OPS_ACCESS_DENIED"},
{"config missing", writeOpsConfigError, opsconfig.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{"config version", writeOpsConfigError, opsconfig.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"},
{"config self approval", writeOpsConfigError, opsconfig.ErrSelfApproval, http.StatusConflict, "SELF_APPROVAL_FORBIDDEN"},
{"config state", writeOpsConfigError, opsconfig.ErrStateConflict, http.StatusConflict, "CONFIG_STATE_CONFLICT"},
{"config gate", writeOpsConfigError, opsconfig.ErrPublishGate, http.StatusConflict, "CONFIG_PUBLISH_GATE_FAILED"},
{"config key", writeOpsConfigError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{"config in progress", writeOpsConfigError, idempotency.ErrInProgress, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS"},
{"config dependency", writeOpsConfigError, errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/ops/test", nil)
test.writer(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestTelemetryErrorsHaveStableMappings(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{&device.TelemetryConflictError{}, http.StatusConflict, "TELEMETRY_EVENT_CONFLICT"},
{device.ErrTelemetryConfigAhead, http.StatusConflict, "CONFIG_VERSION_CONFLICT"},
{device.ErrTelemetryConversationMissing, http.StatusNotFound, "TELEMETRY_CONVERSATION_NOT_FOUND"},
{device.ErrTelemetryCommandConflict, http.StatusConflict, "DEVICE_COMMAND_STATE_CONFLICT"},
{idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{errors.New("database unavailable"), http.StatusServiceUnavailable, "DEVICE_TELEMETRY_UNAVAILABLE"},
}
for _, test := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/device/v2/telemetry:batch", nil)
writeTelemetryError(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("error=%v status=%d body=%s", test.err, recorder.Code, recorder.Body.String())
}
}
}
+590
View File
@@ -0,0 +1,590 @@
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,
})
}
}
@@ -0,0 +1,339 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/orchestration"
)
type fakeConversationCreator struct {
response orchestration.CreateResponse
replayed bool
err error
calls int
}
type fakeConversationActivator struct {
response orchestration.ActivateResponse
replayed bool
err error
calls int
}
type fakeDeviceConversationStateReader struct {
response orchestration.DeviceConversationState
err error
calls int
}
type fakeConversationHeartbeater struct {
response orchestration.HeartbeatResponse
err error
calls int
}
type fakeConversationStopper struct {
response orchestration.StopResponse
replayed bool
err error
calls int
}
type fakeConversationTranscriptSubmitter struct {
response orchestration.TranscriptBatchResponse
replayed bool
err error
calls int
}
func (s *fakeConversationTranscriptSubmitter) Submit(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.TranscriptBatchInput) (orchestration.TranscriptBatchResponse, bool, error) {
s.calls++
return s.response, s.replayed, s.err
}
func (s *fakeConversationStopper) Stop(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.StopInput) (orchestration.StopResponse, bool, error) {
s.calls++
return s.response, s.replayed, s.err
}
func (h *fakeConversationHeartbeater) Heartbeat(context.Context, deviceauth.Identity, string, orchestration.HeartbeatInput) (orchestration.HeartbeatResponse, error) {
h.calls++
return h.response, h.err
}
func (a *fakeConversationActivator) Activate(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.ActivateInput) (orchestration.ActivateResponse, bool, error) {
a.calls++
return a.response, a.replayed, a.err
}
func (r *fakeDeviceConversationStateReader) GetDeviceState(context.Context, deviceauth.Identity, string) (orchestration.DeviceConversationState, error) {
r.calls++
return r.response, r.err
}
func (c *fakeConversationCreator) Create(context.Context, deviceauth.Identity, string, string, []byte, orchestration.CreateInput) (orchestration.CreateResponse, bool, error) {
c.calls++
return c.response, c.replayed, c.err
}
func TestCreateConversationValidatesAndReturnsReplayMarker(t *testing.T) {
testKey := "test-idempotency-key-0001"
creator := &fakeConversationCreator{response: orchestration.CreateResponse{ConversationID: "10000000-0000-0000-0000-000000009999", Status: "ROOM_ISSUED"}, replayed: true}
handler := CreateConversationHandler(creator)
body := `{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true},"network":{"type":"wifi"},"requested_goal":"open_conversation"}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(body))
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Idempotency-Replayed") != "true" || creator.calls != 1 {
t.Fatalf("status=%d replay=%q calls=%d body=%s", recorder.Code, recorder.Header().Get("Idempotency-Replayed"), creator.calls, recorder.Body.String())
}
}
func TestConversationErrorWritersExposeStableStateAndRetryContracts(t *testing.T) {
type errorWriter func(http.ResponseWriter, *http.Request, error)
tests := []struct {
name string
writer errorWriter
err error
status int
code string
}{
{"create unbound", writeCreateConversationError, orchestration.ErrDeviceNotBound, http.StatusConflict, "DEVICE_NOT_BOUND"},
{"create consent", writeCreateConversationError, orchestration.ErrConsentRequired, http.StatusForbidden, "CONSENT_REQUIRED"},
{"create firmware", writeCreateConversationError, orchestration.ErrFirmwareUpdateRequired, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED"},
{"create active", writeCreateConversationError, orchestration.ErrConversationAlreadyActive, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE"},
{"create quota", writeCreateConversationError, orchestration.ErrUsageQuotaExceeded, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED"},
{"create runtime config", writeCreateConversationError, orchestration.ErrRuntimeConfigUnavailable, http.StatusServiceUnavailable, "RUNTIME_CONFIG_UNAVAILABLE"},
{"create context preparation", writeCreateConversationError, &orchestration.CreateFailureError{ConversationID: "10000000-0000-0000-0000-000000009998", FailureCode: "context_preparation_failed"}, http.StatusUnprocessableEntity, "AGENT_CONTEXT_PREPARATION_FAILED"},
{"create key conflict", writeCreateConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"create in progress", writeCreateConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"create dependency", writeCreateConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_CREATE_UNAVAILABLE"},
{"activate missing", writeActivateConversationError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"activate expired", writeActivateConversationError, orchestration.ErrAllocationExpired, http.StatusConflict, "ROOM_ALLOCATION_EXPIRED"},
{"activate rtc mismatch", writeActivateConversationError, orchestration.ErrRTCIdentityMismatch, http.StatusConflict, "RTC_ALLOCATION_MISMATCH"},
{"activate config", writeActivateConversationError, orchestration.ErrConfigVersionConflict, http.StatusConflict, "CONFIG_VERSION_CONFLICT"},
{"activate state", writeActivateConversationError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"activate key conflict", writeActivateConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"activate in progress", writeActivateConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"activate dependency", writeActivateConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_ACTIVATE_UNAVAILABLE"},
{"heartbeat missing", writeHeartbeatError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"heartbeat config", writeHeartbeatError, orchestration.ErrConfigVersionConflict, http.StatusConflict, "CONFIG_VERSION_CONFLICT"},
{"heartbeat state", writeHeartbeatError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"heartbeat dependency", writeHeartbeatError, errors.New("redis unavailable"), http.StatusServiceUnavailable, "CONVERSATION_HEARTBEAT_UNAVAILABLE"},
{"stop missing", writeStopConversationError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"stop state", writeStopConversationError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"stop key conflict", writeStopConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"stop in progress", writeStopConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"stop dependency", writeStopConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_STOP_UNAVAILABLE"},
{"transcript missing", writeTranscriptBatchError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"transcript state", writeTranscriptBatchError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"transcript consent", writeTranscriptBatchError, orchestration.ErrTranscriptStorageNotAllowed, http.StatusForbidden, "TRANSCRIPT_STORAGE_NOT_ALLOWED"},
{"transcript key conflict", writeTranscriptBatchError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"transcript in progress", writeTranscriptBatchError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"transcript dependency", writeTranscriptBatchError, errors.New("database unavailable"), http.StatusServiceUnavailable, "TRANSCRIPT_BATCH_UNAVAILABLE"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", nil)
test.writer(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestCreateConversationReplaysPersistedPreparationFailure(t *testing.T) {
creator := &fakeConversationCreator{
replayed: true,
err: &orchestration.CreateFailureError{
ConversationID: "10000000-0000-0000-0000-000000009998",
FailureCode: "context_preparation_failed",
},
}
handler := CreateConversationHandler(creator)
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(`{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true},"network":{"type":"wifi"},"requested_goal":"open_conversation"}`))
request.Header.Set("Idempotency-Key", "test-idempotency-key-failure")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity || recorder.Header().Get("Idempotency-Replayed") != "true" || !strings.Contains(recorder.Body.String(), `"failure_code":"context_preparation_failed"`) {
t.Fatalf("status=%d replay=%q body=%s", recorder.Code, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestCreateConversationRejectsUnknownAndMissingFields(t *testing.T) {
testKey := "test-idempotency-key-0002"
creator := &fakeConversationCreator{}
for _, body := range []string{
`{"unexpected":true}`,
`{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":false,"audio_tracks":[],"upload_resume":true},"network":{"type":""},"requested_goal":"other"}`,
} {
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(body))
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler := CreateConversationHandler(creator)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("body=%s status=%d response=%s", body, recorder.Code, recorder.Body.String())
}
}
if creator.calls != 0 {
t.Fatalf("invalid requests reached service %d times", creator.calls)
}
}
func TestActivateConversationAcceptsRTCConfirmation(t *testing.T) {
testKey := "test-activate-idempotency-0001"
activator := &fakeConversationActivator{response: orchestration.ActivateResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", Status: "STARTING_AGENT", AcceptedAt: time.Now(),
}}
handler := ActivateConversationHandler(activator)
body := `{"joined_at":"2026-08-23T12:00:00Z","room_id":"room-1","rtc_user_id":"device-1","rtc_join_result":"success","config_revision":1}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/activate", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted || activator.calls != 1 || !containsText(recorder.Body.String(), "STARTING_AGENT") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, activator.calls, recorder.Body.String())
}
}
func TestGetDeviceConversationStateReturnsActionAndStableFailure(t *testing.T) {
conversationID := "10000000-0000-0000-0000-000000009999"
reader := &fakeDeviceConversationStateReader{response: orchestration.DeviceConversationState{
ConversationID: conversationID, Status: "FAILED", Action: "leave_room", FailureCode: "provider_start_rejected",
}}
handler := GetDeviceConversationStateHandler(reader)
request := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/"+conversationID, nil)
request.SetPathValue("id", conversationID)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || reader.calls != 1 || !containsText(recorder.Body.String(), "provider_start_rejected") || !containsText(recorder.Body.String(), "leave_room") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, reader.calls, recorder.Body.String())
}
invalid := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/not-a-uuid", nil)
invalid.SetPathValue("id", "not-a-uuid")
invalid = invalid.WithContext(deviceauth.WithIdentity(invalid.Context(), deviceauth.Identity{PublicID: "device-1"}))
invalidRecorder := httptest.NewRecorder()
handler.ServeHTTP(invalidRecorder, invalid)
if invalidRecorder.Code != http.StatusBadRequest || reader.calls != 1 {
t.Fatalf("invalid status=%d calls=%d body=%s", invalidRecorder.Code, reader.calls, invalidRecorder.Body.String())
}
reader.err = orchestration.ErrConversationNotFound
missing := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/"+conversationID, nil)
missing.SetPathValue("id", conversationID)
missing = missing.WithContext(deviceauth.WithIdentity(missing.Context(), deviceauth.Identity{PublicID: "device-1"}))
missingRecorder := httptest.NewRecorder()
handler.ServeHTTP(missingRecorder, missing)
if missingRecorder.Code != http.StatusNotFound || reader.calls != 2 {
t.Fatalf("missing status=%d calls=%d body=%s", missingRecorder.Code, reader.calls, missingRecorder.Body.String())
}
}
func TestHeartbeatConversationValidatesRequiredRuntimeState(t *testing.T) {
expiresAt := time.Now().Add(time.Minute)
heartbeater := &fakeConversationHeartbeater{response: orchestration.HeartbeatResponse{
LeaseExpiresAt: &expiresAt, Actions: []orchestration.HeartbeatAction{{Type: "continue"}},
DesiredConfigRevision: 1,
}}
handler := HeartbeatConversationHandler(heartbeater)
body := `{"sequence_no":28,"device_time":"2026-08-23T12:00:00Z","monotonic_ms":86234511,"conversation_status":"ACTIVE","rtc":{"joined":true,"uplink":true,"downlink":true},"network":{"type":"wifi","rssi":-62},"firmware_version":"1.0.0","reported_config_revision":1,"recording_queue":{"pending_segments":4,"failed_segments":1,"last_failure_code":"MEDIA_UPLOAD_TIMEOUT","last_failure_at":"2026-08-23T11:59:58Z"},"rtc_credential_expires_at":"2026-08-23T12:05:00Z","command_results":[]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/heartbeat", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || heartbeater.calls != 1 || !containsText(recorder.Body.String(), "continue") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, heartbeater.calls, recorder.Body.String())
}
invalid := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/heartbeat", strings.NewReader(`{"sequence_no":0}`))
invalid = invalid.WithContext(deviceauth.WithIdentity(invalid.Context(), deviceauth.Identity{PublicID: "device-1"}))
invalidRecorder := httptest.NewRecorder()
handler.ServeHTTP(invalidRecorder, invalid)
if invalidRecorder.Code != http.StatusBadRequest || heartbeater.calls != 1 {
t.Fatalf("invalid status=%d calls=%d body=%s", invalidRecorder.Code, heartbeater.calls, invalidRecorder.Body.String())
}
}
func TestStopConversationAcceptsSafeIdempotentRequest(t *testing.T) {
stopper := &fakeConversationStopper{response: orchestration.StopResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", Status: "STOPPING", AcceptedAt: time.Now(),
}, replayed: true}
handler := StopConversationHandler(stopper)
body := `{"reason":"handset_on_hook","device_time":"2026-08-23T12:00:00Z","last_heartbeat_sequence":28,"rtc_left":true,"recording":{"near_segments":18,"far_segments":17}}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/stop", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", "stop-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted || stopper.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" || !containsText(recorder.Body.String(), "STOPPING") {
t.Fatalf("status=%d calls=%d replay=%q body=%s", recorder.Code, stopper.calls, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestSubmitConversationTurnsValidatesAndReturnsReplayMarker(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{response: orchestration.TranscriptBatchResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", AcceptedCount: 1, DuplicateCount: 1,
}, replayed: true}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"device-turn-1","sequence_no":3,"speaker":"subject","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:02Z","final_text":"您好","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/turns:batch", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || submitter.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" || !containsText(recorder.Body.String(), "accepted_count") {
t.Fatalf("status=%d calls=%d replay=%q body=%s", recorder.Code, submitter.calls, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestSubmitConversationTurnsRejectsDuplicateKeysAndInvalidRanges(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"same","sequence_no":1,"speaker":"unknown","start_at":"2026-08-23T12:00:02Z","end_at":"2026-08-23T12:00:00Z","final_text":"","interrupted":false},{"turn_key":"same","sequence_no":1,"speaker":"agent","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:01Z","final_text":"ok","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/turns:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || submitter.calls != 0 || !containsText(recorder.Body.String(), "请求内不能重复") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, submitter.calls, recorder.Body.String())
}
}
func TestSubmitConversationTurnsReturnsStructuredConflict(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{err: &orchestration.TranscriptConflictError{Conflicts: []orchestration.TranscriptConflict{{
TurnKey: "turn-1", SequenceNo: 1, Reason: "TURN_KEY_OR_SEQUENCE_ALREADY_USED",
}}}}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"turn-1","sequence_no":1,"speaker":"agent","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:01Z","final_text":"different","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/turns:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !containsText(recorder.Body.String(), "TRANSCRIPT_TURN_CONFLICT") || !containsText(recorder.Body.String(), "TURN_KEY_OR_SEQUENCE_ALREADY_USED") {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+33
View File
@@ -0,0 +1,33 @@
package httpapi
import (
"errors"
"net/http"
"data-terminal-cloud/internal/deviceauth"
)
type DeviceRequestVerifier interface {
Verify(*http.Request) (deviceauth.Identity, error)
}
func DeviceAuth(verifier DeviceRequestVerifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
identity, err := verifier.Verify(request)
if err != nil {
if errors.Is(err, deviceauth.ErrCredentialsMissing) ||
errors.Is(err, deviceauth.ErrCredentialInvalid) ||
errors.Is(err, deviceauth.ErrDeviceInactive) ||
errors.Is(err, deviceauth.ErrRequestExpired) ||
errors.Is(err, deviceauth.ErrReplayDetected) {
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
Code: "DEVICE_AUTHENTICATION_UNAVAILABLE", Message: "设备身份服务暂时不可用", Retryable: true,
})
return
}
next.ServeHTTP(writer, request.WithContext(deviceauth.WithIdentity(request.Context(), identity)))
})
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"data-terminal-cloud/internal/deviceauth"
)
type fakeDeviceVerifier struct {
identity deviceauth.Identity
err error
}
func (v fakeDeviceVerifier) Verify(*http.Request) (deviceauth.Identity, error) {
return v.identity, v.err
}
func TestDeviceAuthAddsIdentityContext(t *testing.T) {
expected := deviceauth.Identity{PublicID: "device-1", TenantID: "tenant-1"}
handler := DeviceAuth(fakeDeviceVerifier{identity: expected}, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
actual, ok := deviceauth.FromContext(request.Context())
if !ok || actual.PublicID != expected.PublicID {
t.Fatalf("identity = %#v, ok = %v", actual, ok)
}
writer.WriteHeader(http.StatusNoContent)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/device/v2/config", nil))
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d", recorder.Code)
}
}
func TestDeviceAuthDoesNotExposeCredentialFailure(t *testing.T) {
for _, authError := range []error{
deviceauth.ErrCredentialsMissing, deviceauth.ErrCredentialInvalid,
deviceauth.ErrDeviceInactive, deviceauth.ErrRequestExpired, deviceauth.ErrReplayDetected,
} {
handler := DeviceAuth(fakeDeviceVerifier{err: authError}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("downstream must not be called")
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/device/v2/config", nil))
if recorder.Code != http.StatusUnauthorized || !containsText(recorder.Body.String(), "DEVICE_AUTHENTICATION_FAILED") {
t.Fatalf("error %v returned status %d and body %s", authError, recorder.Code, recorder.Body.String())
}
}
handler := DeviceAuth(fakeDeviceVerifier{err: errors.New("redis down")}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/device/v2/config", nil))
if recorder.Code != http.StatusServiceUnavailable || !containsText(recorder.Body.String(), `"retryable":true`) {
t.Fatalf("unexpected unavailable response: %d %s", recorder.Code, recorder.Body.String())
}
}
func containsText(value, part string) bool {
for index := 0; index+len(part) <= len(value); index++ {
if value[index:index+len(part)] == part {
return true
}
}
return false
}
+125
View File
@@ -0,0 +1,125 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/saas"
)
type DeviceCommandService interface {
PullCommands(context.Context, deviceauth.Identity, int) ([]device.Command, error)
AcknowledgeCommand(context.Context, deviceauth.Identity, string, device.CommandAcknowledgement) (device.Command, error)
}
func PullDeviceCommandsHandler(service DeviceCommandService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := service.PullCommands(r.Context(), id, limit)
if err != nil {
WriteAPIError(w, r, http.StatusServiceUnavailable, Error{Code: "DEVICE_COMMANDS_UNAVAILABLE", Message: "暂时无法读取设备命令", Retryable: true})
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func AcknowledgeDeviceCommandHandler(service DeviceCommandService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
var input struct {
Status string `json:"status"`
ResultSummary map[string]any `json:"result_summary"`
}
if !decodeWebJSON(w, r, &input, "设备命令回执不正确") {
return
}
input.Status = strings.ToUpper(strings.TrimSpace(input.Status))
if input.Status != "ACKNOWLEDGED" && input.Status != "SUCCEEDED" && input.Status != "FAILED" {
writeValidation(w, r, "设备命令回执不正确", FieldError{Field: "status", Message: "必须为ACKNOWLEDGED、SUCCEEDED或FAILED"})
return
}
if input.ResultSummary == nil {
input.ResultSummary = map[string]any{}
}
item, err := service.AcknowledgeCommand(r.Context(), id, r.PathValue("id"), device.CommandAcknowledgement{Status: input.Status, ResultSummary: input.ResultSummary})
if err != nil {
WriteError(w, r, http.StatusConflict, "DEVICE_COMMAND_STATE_CONFLICT", "命令不存在、已过期或状态不允许回执")
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func ListDeviceCommandsHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
items, err := service.ListDeviceCommands(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateDeviceCommandHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(key) < 16 || len(key) > 128 {
writeValidation(w, r, "缺少有效的重复请求识别码", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
return
}
var input struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
ExpiresInSec int `json:"expires_in_sec"`
}
if !decodeWebJSON(w, r, &input, "设备命令参数不正确") {
return
}
input.Type = strings.TrimSpace(input.Type)
if input.Type != "apply_config" && input.Type != "reboot" && input.Type != "diagnostics" && input.Type != "firmware_update" {
writeValidation(w, r, "设备命令参数不正确", FieldError{Field: "type", Message: "命令类型不受支持"})
return
}
if input.ExpiresInSec < 30 || input.ExpiresInSec > 86400 {
writeValidation(w, r, "设备命令参数不正确", FieldError{Field: "expires_in_sec", Message: "必须为30至86400秒"})
return
}
if input.Payload == nil {
input.Payload = map[string]any{}
}
item, created, err := service.CreateDeviceCommand(r.Context(), p, r.PathValue("id"), key, RequestID(r.Context()), input.Type, input.Payload, input.ExpiresInSec)
if err != nil {
writeSaaSError(w, r, err)
return
}
status := http.StatusAccepted
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
WriteJSON(w, r, status, item)
})
}
@@ -0,0 +1,108 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
)
type fakeDeviceCommandService struct {
items []device.Command
pullErr error
ackErr error
pulledID deviceauth.Identity
pulledLimit int
ackedID deviceauth.Identity
ackedRef string
ackedInput device.CommandAcknowledgement
}
func (f *fakeDeviceCommandService) PullCommands(_ context.Context, id deviceauth.Identity, limit int) ([]device.Command, error) {
f.pulledID, f.pulledLimit = id, limit
return f.items, f.pullErr
}
func (f *fakeDeviceCommandService) AcknowledgeCommand(_ context.Context, id deviceauth.Identity, ref string, input device.CommandAcknowledgement) (device.Command, error) {
f.ackedID, f.ackedRef, f.ackedInput = id, ref, input
if f.ackErr != nil {
return device.Command{}, f.ackErr
}
return device.Command{ID: ref, Type: "reboot", Status: input.Status, Payload: json.RawMessage(`{}`), ExpiresAt: time.Now().UTC()}, nil
}
func deviceCommandRequest(method, target, body string, authenticated bool) *http.Request {
request := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if authenticated {
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{ID: "internal-device", PublicID: "device-1", TenantID: "tenant-1"}))
}
return request
}
func TestPullDeviceCommandsAuthenticationSuccessAndFailure(t *testing.T) {
service := &fakeDeviceCommandService{items: []device.Command{{ID: "command-1", Type: "apply_config", Status: "PENDING", Payload: json.RawMessage(`{}`)}}}
handler := PullDeviceCommandsHandler(service)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, deviceCommandRequest(http.MethodGet, "/device/v2/commands?limit=7", "", false))
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "DEVICE_AUTHENTICATION_FAILED") {
t.Fatalf("unauthenticated status=%d body=%s", recorder.Code, recorder.Body.String())
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, deviceCommandRequest(http.MethodGet, "/device/v2/commands?limit=7", "", true))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "command-1") || service.pulledLimit != 7 || service.pulledID.PublicID != "device-1" {
t.Fatalf("success status=%d body=%s service=%+v", recorder.Code, recorder.Body.String(), service)
}
service.pullErr = errors.New("database unavailable")
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, deviceCommandRequest(http.MethodGet, "/device/v2/commands", "", true))
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "DEVICE_COMMANDS_UNAVAILABLE") || !strings.Contains(recorder.Body.String(), `"retryable":true`) {
t.Fatalf("failure status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAcknowledgeDeviceCommandValidationSuccessAndConflict(t *testing.T) {
service := &fakeDeviceCommandService{}
handler := AcknowledgeDeviceCommandHandler(service)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, deviceCommandRequest(http.MethodPost, "/device/v2/commands/command-1/ack", `{"status":"SUCCEEDED"}`, false))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", recorder.Code)
}
request := deviceCommandRequest(http.MethodPost, "/device/v2/commands/command-1/ack", `{"status":"unknown"}`, true)
request.SetPathValue("id", "command-1")
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") {
t.Fatalf("validation status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = deviceCommandRequest(http.MethodPost, "/device/v2/commands/command-1/ack", `{"status":" succeeded "}`, true)
request.SetPathValue("id", "command-1")
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || service.ackedRef != "command-1" || service.ackedInput.Status != "SUCCEEDED" || service.ackedInput.ResultSummary == nil {
t.Fatalf("success status=%d body=%s input=%+v", recorder.Code, recorder.Body.String(), service.ackedInput)
}
service.ackErr = errors.New("state conflict")
request = deviceCommandRequest(http.MethodPost, "/device/v2/commands/command-1/ack", `{"status":"FAILED","result_summary":{"result_code":"SIM_FAIL"}}`, true)
request.SetPathValue("id", "command-1")
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "DEVICE_COMMAND_STATE_CONFLICT") || service.ackedInput.ResultSummary["result_code"] != "SIM_FAIL" {
t.Fatalf("conflict status=%d body=%s input=%+v", recorder.Code, recorder.Body.String(), service.ackedInput)
}
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
)
type DeviceService interface {
ReportBootstrap(context.Context, deviceauth.Identity, device.BootstrapReport) (device.Bootstrap, error)
GetConfiguration(context.Context, deviceauth.Identity) (device.Configuration, error)
}
type bootstrapResponse struct {
ServerTime time.Time `json:"server_time"`
ClockSkewMS int64 `json:"clock_skew_ms"`
Device device.Bootstrap `json:"device"`
}
func DeviceBootstrapHandler(service DeviceService) 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
}
report, fieldErrors := parseBootstrapReport(request)
if len(fieldErrors) > 0 {
WriteAPIError(writer, request, http.StatusBadRequest, Error{
Code: "VALIDATION_FAILED", Message: "设备启动参数不正确", FieldErrors: fieldErrors,
})
return
}
result, err := service.ReportBootstrap(request.Context(), identity, report)
if err != nil {
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
Code: "DEVICE_BOOTSTRAP_FAILED", Message: "暂时无法完成设备启动", Retryable: true,
})
return
}
now := time.Now().UTC()
WriteJSON(writer, request, http.StatusOK, bootstrapResponse{
ServerTime: now, ClockSkewMS: now.Sub(report.DeviceTime).Milliseconds(), Device: result,
})
})
}
func DeviceConfigHandler(service DeviceService) 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
}
result, err := service.GetConfiguration(request.Context(), identity)
if err != nil {
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{
Code: "DEVICE_CONFIG_UNAVAILABLE", Message: "暂时无法读取设备配置", Retryable: true,
})
return
}
etag := concurrency.FormatETag(result.Revision)
writer.Header().Set("ETag", etag)
if request.Header.Get("If-None-Match") == etag {
writer.WriteHeader(http.StatusNotModified)
return
}
WriteJSON(writer, request, http.StatusOK, result)
})
}
func parseBootstrapReport(request *http.Request) (device.BootstrapReport, []FieldError) {
query := request.URL.Query()
report := device.BootstrapReport{
FirmwareVersion: strings.TrimSpace(query.Get("firmware_version")),
BoardRevision: strings.TrimSpace(query.Get("board_rev")),
RTCSDKVersion: strings.TrimSpace(query.Get("rtc_sdk_version")),
Codec: strings.TrimSpace(query.Get("codec")),
Network: strings.TrimSpace(query.Get("network")),
}
errors := make([]FieldError, 0)
if report.FirmwareVersion == "" {
errors = append(errors, FieldError{Field: "firmware_version", Message: "不能为空"})
}
for _, field := range []struct {
name string
value string
max int
}{
{"firmware_version", report.FirmwareVersion, 80},
{"board_rev", report.BoardRevision, 80},
{"rtc_sdk_version", report.RTCSDKVersion, 80},
{"codec", report.Codec, 40},
{"network", report.Network, 80},
} {
if len(field.value) > field.max {
errors = append(errors, FieldError{Field: field.name, Message: "长度超出限制"})
}
}
deviceTime, err := time.Parse(time.RFC3339, query.Get("device_time"))
if err != nil {
errors = append(errors, FieldError{Field: "device_time", Message: "必须是RFC3339时间"})
} else {
report.DeviceTime = deviceTime
}
if value := query.Get("sample_rate"); value != "" {
report.SampleRate, err = strconv.Atoi(value)
if err != nil || report.SampleRate <= 0 {
errors = append(errors, FieldError{Field: "sample_rate", Message: "必须是正整数"})
}
}
return report, errors
}
+63
View File
@@ -0,0 +1,63 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
)
type fakeDeviceService struct {
bootstrap device.Bootstrap
config device.Configuration
}
func (s fakeDeviceService) ReportBootstrap(context.Context, deviceauth.Identity, device.BootstrapReport) (device.Bootstrap, error) {
return s.bootstrap, nil
}
func (s fakeDeviceService) GetConfiguration(context.Context, deviceauth.Identity) (device.Configuration, error) {
return s.config, nil
}
func TestBootstrapValidationAndResponse(t *testing.T) {
service := fakeDeviceService{bootstrap: device.Bootstrap{DeviceID: "device-1", Status: "registered", ConfigRevision: 3}}
handler := DeviceBootstrapHandler(service)
badRequest := httptest.NewRequest(http.MethodGet, "/device/v2/bootstrap", nil)
badRequest = badRequest.WithContext(deviceauth.WithIdentity(badRequest.Context(), deviceauth.Identity{PublicID: "device-1"}))
badRecorder := httptest.NewRecorder()
handler.ServeHTTP(badRecorder, badRequest)
if badRecorder.Code != http.StatusBadRequest {
t.Fatalf("validation status = %d", badRecorder.Code)
}
request := httptest.NewRequest(http.MethodGet, "/device/v2/bootstrap?firmware_version=1.0&device_time=2026-08-23T12%3A00%3A00Z&sample_rate=16000", nil)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !json.Valid(recorder.Body.Bytes()) || !containsText(recorder.Body.String(), `"device_id":"device-1"`) {
t.Fatalf("unexpected response: %d %s", recorder.Code, recorder.Body.String())
}
}
func TestConfigUsesETagAndConditionalGet(t *testing.T) {
service := fakeDeviceService{config: device.Configuration{DeviceID: "device-1", Revision: 3, DesiredConfig: json.RawMessage(`{"codec":"opus"}`)}}
handler := DeviceConfigHandler(service)
for _, test := range []struct {
ifNoneMatch string
status int
}{{"", http.StatusOK}, {`"v3"`, http.StatusNotModified}} {
request := httptest.NewRequest(http.MethodGet, "/device/v2/config", nil)
request.Header.Set("If-None-Match", test.ifNoneMatch)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != test.status || recorder.Header().Get("ETag") != `"v3"` {
t.Fatalf("If-None-Match=%q status=%d etag=%q", test.ifNoneMatch, recorder.Code, recorder.Header().Get("ETag"))
}
}
}
+432
View File
@@ -0,0 +1,432 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/idempotency"
)
func ListOpsIntegrationIssuesHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListIntegrationIssues(r.Context(), principal, tenantID, r.URL.Query().Get("status"))
if err != nil {
writeDiagnosticError(w, r, err)
return
}
summary, err := service.SummarizeIntegrationIssues(r.Context(), principal, tenantID)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items, "summary": summary})
})
}
func CreateOpsIntegrationIssueHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input diagnostics.CreateIntegrationIssueInput
if !decodeWebJSON(w, r, &input, "联调问题参数不正确") {
return
}
item, created, err := service.CreateIntegrationIssue(r.Context(), principal, tenantID, key, RequestID(r.Context()), input)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func UpdateOpsIntegrationIssueHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
expectedVersion, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var input diagnostics.UpdateIntegrationIssueInput
if !decodeWebJSON(w, r, &input, "联调问题更新参数不正确") {
return
}
item, err := service.UpdateIntegrationIssue(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expectedVersion, input)
if err != nil {
if errors.Is(err, concurrency.ErrVersionConflict) {
writeETagError(w, r, err)
return
}
writeDiagnosticError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func SearchOpsDiagnosticsHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.Search(r.Context(), principal, tenantID, strings.TrimSpace(r.URL.Query().Get("field")), strings.TrimSpace(r.URL.Query().Get("value")))
if err != nil {
writeDiagnosticError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func ListOpsExternalDependenciesHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListExternalDependencies(r.Context(), principal, tenantID)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func StopOpsDiagnosticConversationHandler(service *diagnostics.Service) http.Handler {
return diagnosticConversationActionHandler(service, "stop")
}
func ReconcileOpsDiagnosticConversationHandler(service *diagnostics.Service) http.Handler {
return diagnosticConversationActionHandler(service, "reconcile")
}
func diagnosticConversationActionHandler(service *diagnostics.Service, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "联调修复参数不正确") {
return
}
var result diagnostics.ActionResult
var created bool
var err error
if action == "stop" {
result, created, err = service.StopConversation(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.Reason)
} else {
result, created, err = service.ReconcileConversation(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.Reason)
}
if err != nil {
writeDiagnosticError(w, r, err)
return
}
if !created {
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, result)
})
}
func RefreshOpsDiagnosticDeviceConfigHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "配置刷新参数不正确") {
return
}
result, created, err := service.RefreshDeviceConfig(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.Reason)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, result)
})
}
func ListOpsDiagnosticJobsHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListDeadJobs(r.Context(), principal, tenantID)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func RetryOpsDiagnosticJobHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "任务重试参数不正确") {
return
}
result, created, err := service.RetryDeadJob(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.Reason)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
if !created {
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, result)
})
}
func RevokeOpsDiagnosticDeviceHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "设备吊销参数不正确") {
return
}
result, created, err := service.RevokeDevice(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.Reason)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
if !created {
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, result)
})
}
func ExportOpsDiagnosticBundleHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Field string `json:"field"`
Value string `json:"value"`
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "诊断包参数不正确") {
return
}
bundle, created, err := service.ExportBundle(r.Context(), principal, tenantID, body.Field, body.Value, key, RequestID(r.Context()), body.Reason)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, bundle)
})
}
func ExportOpsAcceptanceSnapshotHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input diagnostics.AcceptanceSnapshotInput
if !decodeWebJSON(w, r, &input, "发布候选验收快照参数不正确") {
return
}
snapshot, created, err := service.ExportAcceptanceSnapshot(r.Context(), principal, tenantID, key, RequestID(r.Context()), input)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, snapshot)
})
}
func ExportOpsPilotDailySnapshotHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input diagnostics.PilotDailySnapshotInput
if !decodeWebJSON(w, r, &input, "试点运行日报参数不正确") {
return
}
snapshot, created, err := service.ExportPilotDailySnapshot(r.Context(), principal, tenantID, key, RequestID(r.Context()), input)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, snapshot)
})
}
func ExportOpsPilotWeeklySnapshotHandler(service *diagnostics.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input diagnostics.PilotWeeklySnapshotInput
if !decodeWebJSON(w, r, &input, "试点运行周报参数不正确") {
return
}
snapshot, created, err := service.ExportPilotWeeklySnapshot(r.Context(), principal, tenantID, key, RequestID(r.Context()), input)
if err != nil {
writeDiagnosticError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, snapshot)
})
}
func writeDiagnosticError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, diagnostics.ErrInvalid):
writeValidation(w, r, "联调检索或修复参数不正确", FieldError{Field: "diagnostics", Message: "检索字段、编号或操作原因不符合要求"})
case errors.Is(err, diagnostics.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "当前账号没有所需的内部运营权限")
case errors.Is(err, diagnostics.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "指定设备或通话不存在")
case errors.Is(err, diagnostics.ErrStateConflict):
WriteError(w, r, http.StatusConflict, "DIAGNOSTIC_STATE_CONFLICT", "当前资源状态不允许执行该修复动作")
case errors.Is(err, idempotency.ErrKeyConflict):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已被不同请求使用")
case errors.Is(err, idempotency.ErrInProgress):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS", "相同请求正在处理中")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "联调诊断处理失败")
}
}
@@ -0,0 +1,167 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestDiagnosticHandlersSearchRefreshJobsAndBundle(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := diagnostics.NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
tenantID := "10000000-0000-0000-0000-000000000010"
deviceID := "10000000-0000-0000-0000-000000000511"
refreshKey := "http-diagnostic-refresh-" + uuid.NewString()
bundleKey := "http-diagnostic-bundle-" + uuid.NewString()
requestPrefix := "http-diagnostic-"
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE idempotency_key=$1`, "ops-refresh:"+refreshKey)
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{refreshKey, bundleKey})
}()
setHeaders := func(request *http.Request, key string) {
request.Header.Set("X-Tenant-Id", tenantID)
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
}
withRequestID := func(request *http.Request, id string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), requestIDKey, id))
}
request := authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/search?field=device_id&value="+deviceID, "", admin)
setHeaders(request, "")
recorder := httptest.NewRecorder()
SearchOpsDiagnosticsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), deviceID) {
t.Fatalf("search status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+deviceID+"/refresh-config", `{"reason":"HTTP诊断刷新配置"}`, admin), requestPrefix+"refresh")
request.SetPathValue("id", deviceID)
setHeaders(request, refreshKey)
recorder = httptest.NewRecorder()
RefreshOpsDiagnosticDeviceConfigHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), `"queued":true`) {
t.Fatalf("refresh status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+deviceID+"/refresh-config", `{"reason":"HTTP诊断刷新配置"}`, admin), requestPrefix+"refresh-replay")
request.SetPathValue("id", deviceID)
setHeaders(request, refreshKey)
recorder = httptest.NewRecorder()
RefreshOpsDiagnosticDeviceConfigHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
t.Fatalf("refresh replay status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/jobs", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsDiagnosticJobsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("list jobs status=%d body=%s", recorder.Code, recorder.Body.String())
}
bundleBody := `{"field":"device_id","value":"` + deviceID + `","reason":"导出HTTP脱敏诊断证据"}`
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", bundleBody, admin), requestPrefix+"bundle")
setHeaders(request, bundleKey)
recorder = httptest.NewRecorder()
ExportOpsDiagnosticBundleHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), "ops-diagnostic-minimal-v1") || !strings.Contains(recorder.Body.String(), deviceID) {
t.Fatalf("bundle status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", bundleBody, admin), requestPrefix+"bundle-replay")
setHeaders(request, bundleKey)
recorder = httptest.NewRecorder()
ExportOpsDiagnosticBundleHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
t.Fatalf("bundle replay status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestDiagnosticHandlersRejectInvalidRequestsBeforeService(t *testing.T) {
principal := identity.Principal{Issuer: "issuer", Subject: "subject"}
tenantID, resourceID := uuid.NewString(), uuid.NewString()
tests := []struct {
name string
handler http.Handler
request *http.Request
status int
code string
}{
{"search unauthenticated", SearchOpsDiagnosticsHandler(nil), httptest.NewRequest(http.MethodGet, "/api/v1/ops/diagnostics/search", nil), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"search missing tenant", SearchOpsDiagnosticsHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/diagnostics/search", "", principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"stop missing idempotency", StopOpsDiagnosticConversationHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/conversations/"+resourceID+"/stop", `{}`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"reconcile malformed body", ReconcileOpsDiagnosticConversationHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/conversations/"+resourceID+"/reconcile", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"retry malformed body", RetryOpsDiagnosticJobHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/jobs/"+resourceID+"/retry", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"revoke malformed body", RevokeOpsDiagnosticDeviceHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/devices/"+resourceID+"/revoke", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"bundle malformed body", ExportOpsDiagnosticBundleHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/diagnostics/bundles", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
if !strings.Contains(item.name, "unauthenticated") && !strings.Contains(item.name, "missing tenant") {
item.request.Header.Set("X-Tenant-Id", tenantID)
}
if strings.Contains(item.name, "malformed") {
item.request.Header.Set("Idempotency-Key", "http-diagnostic-invalid-key")
}
if strings.Contains(item.name, "stop") || strings.Contains(item.name, "reconcile") || strings.Contains(item.name, "retry") || strings.Contains(item.name, "revoke") {
item.request.SetPathValue("id", resourceID)
}
recorder := httptest.NewRecorder()
item.handler.ServeHTTP(recorder, item.request)
if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestWriteDiagnosticErrorStableMappings(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{diagnostics.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{diagnostics.ErrAccessDenied, http.StatusForbidden, "OPS_ACCESS_DENIED"},
{diagnostics.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{diagnostics.ErrStateConflict, http.StatusConflict, "DIAGNOSTIC_STATE_CONFLICT"},
{idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{idempotency.ErrInProgress, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS"},
{errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, item := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/ops/diagnostics", nil)
writeDiagnosticError(recorder, request, item.err)
if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) {
t.Fatalf("error=%v status=%d body=%s", item.err, recorder.Code, recorder.Body.String())
}
}
}
+105
View File
@@ -0,0 +1,105 @@
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/webevents"
)
func StreamWebEventsHandler(service *webevents.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := identity.PrincipalFromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "AUTHENTICATION_REQUIRED", "请先登录后再访问")
return
}
lastIDText := strings.TrimSpace(r.Header.Get("Last-Event-ID"))
var cursor int64
var err error
if lastIDText == "" {
cursor, err = service.StartingCursor(r.Context(), principal)
} else {
cursor, err = strconv.ParseInt(lastIDText, 10, 64)
if err == nil && cursor < 1 {
err = errors.New("cursor must be positive")
}
if err == nil {
err = service.ValidateCursor(r.Context(), principal, cursor)
}
}
if err != nil {
status := http.StatusBadRequest
code := "EVENT_CURSOR_INVALID"
message := "状态事件编号无效"
if errors.Is(err, webevents.ErrCursorUnavailable) {
status = http.StatusGone
code = "EVENT_CURSOR_EXPIRED"
message = "状态事件续传窗口已过期,请重新载入页面"
}
WriteError(w, r, status, code, message)
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
if _, err := fmt.Fprintf(w, "retry: 2000\nid: %d\n\n", cursor); err != nil {
return
}
if err := controller.Flush(); err != nil {
return
}
poll := time.NewTicker(500 * time.Millisecond)
heartbeat := time.NewTicker(10 * time.Second)
end := time.NewTimer(25 * time.Second)
defer poll.Stop()
defer heartbeat.Stop()
defer end.Stop()
for {
select {
case <-r.Context().Done():
return
case <-end.C:
return
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
return
}
if err := controller.Flush(); err != nil {
return
}
case <-poll.C:
items, err := service.ListAfter(r.Context(), principal, cursor, 100)
if err != nil {
return
}
for _, item := range items {
payload, err := json.Marshal(item)
if err != nil {
return
}
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", item.ID, item.Type, payload); err != nil {
return
}
cursor = item.ID
}
if len(items) > 0 {
if err := controller.Flush(); err != nil {
return
}
}
}
}
})
}
+342
View File
@@ -0,0 +1,342 @@
package httpapi
import (
"net/http"
"regexp"
"strings"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/saas"
)
var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func RegisterFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var in struct {
KeyID string `json:"key_id"`
PublicKey string `json:"public_key_base64"`
}
if !decodeWebJSON(w, r, &in, "固件签名密钥参数不正确") {
return
}
in.KeyID = strings.TrimSpace(in.KeyID)
in.PublicKey = strings.TrimSpace(in.PublicKey)
if in.KeyID == "" || in.PublicKey == "" {
writeValidation(w, r, "固件签名密钥参数不正确", FieldError{Field: "key", Message: "密钥编号和Ed25519公钥均为必填"})
return
}
if err := service.RegisterFirmwareSigningKey(r.Context(), p, r.PathValue("id"), RequestID(r.Context()), in.KeyID, in.PublicKey); err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusCreated, map[string]any{"key_id": in.KeyID, "status": "ACTIVE"})
})
}
func ListFirmwareSigningKeysHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
items, err := service.ListFirmwareSigningKeys(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func RevokeFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var input struct {
KeyID string `json:"key_id"`
}
if !decodeWebJSON(w, r, &input, "固件签名密钥吊销参数不正确") {
return
}
input.KeyID = strings.TrimSpace(input.KeyID)
if input.KeyID == "" || len([]rune(input.KeyID)) > 120 {
writeValidation(w, r, "固件签名密钥吊销参数不正确", FieldError{Field: "key_id", Message: "密钥编号必须为1至120个字符"})
return
}
item, err := service.RevokeFirmwareSigningKey(r.Context(), p, r.PathValue("id"), RequestID(r.Context()), input.KeyID)
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func PrepareFirmwareReleaseHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var in struct {
Model string `json:"model"`
HardwareRevision string `json:"hardware_revision"`
Version string `json:"version"`
ExpectedSize int64 `json:"expected_size"`
ExpectedDigest string `json:"expected_digest"`
Signature string `json:"signature"`
SigningKeyID string `json:"signing_key_id"`
MinimumSecurityVersion string `json:"minimum_security_version"`
ReleaseNotes string `json:"release_notes"`
}
if !decodeWebJSON(w, r, &in, "固件版本参数不正确") {
return
}
in.Model = strings.TrimSpace(in.Model)
in.HardwareRevision = strings.TrimSpace(in.HardwareRevision)
in.Version = strings.TrimSpace(in.Version)
in.ExpectedDigest = strings.ToLower(strings.TrimSpace(in.ExpectedDigest))
in.Signature = strings.TrimSpace(in.Signature)
in.SigningKeyID = strings.TrimSpace(in.SigningKeyID)
if in.Model == "" || in.Version == "" || in.ExpectedSize < 1 || in.ExpectedSize > 268435456 || !sha256Pattern.MatchString(in.ExpectedDigest) || in.Signature == "" || in.SigningKeyID == "" {
writeValidation(w, r, "固件版本参数不正确", FieldError{Field: "firmware", Message: "型号、版本、1至256MB大小、SHA-256、Ed25519签名和密钥编号均为必填"})
return
}
item, err := service.PrepareFirmwareRelease(r.Context(), p, r.PathValue("id"), RequestID(r.Context()), saas.PrepareFirmwareInput{Model: in.Model, HardwareRevision: in.HardwareRevision, Version: in.Version, ExpectedSize: in.ExpectedSize, ExpectedDigest: in.ExpectedDigest, Signature: in.Signature, SigningKeyID: in.SigningKeyID, MinimumSecurityVersion: strings.TrimSpace(in.MinimumSecurityVersion), ReleaseNotes: strings.TrimSpace(in.ReleaseNotes)})
if err != nil {
writeSaaSError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusCreated, item)
})
}
func ListFirmwareReleasesHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
items, err := service.ListFirmwareReleases(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func GetFirmwareReleaseImpactHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.GetFirmwareReleaseImpact(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func FinalizeFirmwareReleaseHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.FinalizeFirmwareRelease(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func RenewFirmwareUploadHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.RenewFirmwareUpload(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func PauseFirmwareReleaseHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.PauseFirmwareRelease(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func ResumeFirmwareReleaseHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.ResumeFirmwareRelease(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func RetireFirmwareReleaseHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.RetireFirmwareRelease(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func StartFirmwareRolloutHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var in struct {
ReleaseID string `json:"release_id"`
Name string `json:"name"`
TargetPercentage int `json:"target_percentage"`
DeviceIDs []string `json:"device_ids"`
}
if !decodeWebJSON(w, r, &in, "固件灰度批次参数不正确") {
return
}
in.ReleaseID = strings.TrimSpace(in.ReleaseID)
in.Name = strings.TrimSpace(in.Name)
if in.ReleaseID == "" || in.Name == "" || in.TargetPercentage < 1 || in.TargetPercentage > 100 || len(in.DeviceIDs) == 0 {
writeValidation(w, r, "固件灰度批次参数不正确", FieldError{Field: "rollout", Message: "版本、批次名称、1至100灰度比例和目标设备均为必填"})
return
}
item, err := service.StartFirmwareRollout(r.Context(), p, r.PathValue("id"), in.ReleaseID, RequestID(r.Context()), in.Name, in.TargetPercentage, in.DeviceIDs)
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusAccepted, item)
})
}
func ListFirmwareRolloutsHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
items, err := service.ListFirmwareRollouts(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func DeviceFirmwareDownloadHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
url, until, err := service.FirmwareDownloadForDevice(r.Context(), id, r.PathValue("id"))
if err != nil {
WriteError(w, r, http.StatusForbidden, "FIRMWARE_DOWNLOAD_DENIED", "设备不在该固件批次中或版本不可下载")
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"download_url": url, "download_until": until})
})
}
func GetFirmwareRolloutHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.GetFirmwareRollout(r.Context(), p, r.PathValue("id"))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func PauseFirmwareRolloutHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.PauseFirmwareRollout(r.Context(), p, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func RollbackFirmwareRolloutHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var in struct {
ReleaseID string `json:"rollback_release_id"`
}
if !decodeWebJSON(w, r, &in, "固件回滚参数不正确") {
return
}
in.ReleaseID = strings.TrimSpace(in.ReleaseID)
if in.ReleaseID == "" {
writeValidation(w, r, "固件回滚参数不正确", FieldError{Field: "rollback_release_id", Message: "必须选择已验证的回滚版本"})
return
}
item, err := service.StartFirmwareRollback(r.Context(), p, r.PathValue("id"), in.ReleaseID, RequestID(r.Context()))
if err != nil {
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusAccepted, item)
})
}
@@ -0,0 +1,303 @@
package httpapi
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type httpFirmwareStore struct{}
func (*httpFirmwareStore) PresignUpload(_ context.Context, ref string, _ time.Duration) (string, error) {
return "https://objects.invalid/upload/" + ref, nil
}
func (*httpFirmwareStore) PresignDownload(_ context.Context, ref string, _ time.Duration, filename string) (string, error) {
return "https://objects.invalid/download/" + ref + "?filename=" + filename, nil
}
func (*httpFirmwareStore) VerifyDigest(context.Context, string, string, int64) error { return nil }
func (*httpFirmwareStore) Remove(context.Context, string) error { return nil }
func TestFirmwareWriteHandlers(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service := saas.NewService(pool, audit.NewRepository(), nil, nil)
service.ConfigureFirmwareStore(&httpFirmwareStore{}, 5*time.Minute)
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
householdID := "10000000-0000-0000-0000-000000000211"
deviceID := "10000000-0000-0000-0000-000000000511"
keyID := "firmware-http-" + uuid.NewString()
version := "http-" + uuid.NewString()
var releaseID, retiredReleaseID, rolloutID string
defer func() {
if rolloutID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, rolloutID)
_, _ = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE public_id=$1::uuid`, rolloutID)
}
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE idempotency_key LIKE 'firmware-rollout:%' AND payload->>'release_id'=$1`, releaseID)
if releaseID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, releaseID)
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, releaseID)
}
if retiredReleaseID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, retiredReleaseID)
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, retiredReleaseID)
}
_, _ = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE key_id=$1`, keyID)
}()
request := authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys", `{}`, principal)
request.SetPathValue("id", householdID)
recorder := httptest.NewRecorder()
RegisterFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid key status=%d body=%s", recorder.Code, recorder.Body.String())
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
keyBody, _ := json.Marshal(map[string]any{"key_id": keyID, "public_key_base64": base64.StdEncoding.EncodeToString(publicKey)})
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys", string(keyBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
RegisterFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("register key status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/households/"+householdID+"/firmware-signing-keys", "", principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
ListFirmwareSigningKeysHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list signing keys status=%d body=%s", recorder.Code, recorder.Body.String())
}
var keysResponse struct {
Data struct {
Items []saas.FirmwareSigningKey `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &keysResponse); err != nil || len(keysResponse.Data.Items) != 1 || keysResponse.Data.Items[0].KeyID != keyID {
t.Fatalf("signing keys response=%+v err=%v", keysResponse, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", `{"model":"simulated-terminal","version":"bad","expected_size":1,"expected_digest":"bad","signature":"bad","signing_key_id":"bad"}`, principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid release status=%d body=%s", recorder.Code, recorder.Body.String())
}
image := []byte("synthetic firmware through HTTP")
digestBytes := sha256.Sum256(image)
digest := hex.EncodeToString(digestBytes[:])
manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", "simulated-terminal", "sim-rev-a", version, digest, len(image))
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest)))
releaseBody, _ := json.Marshal(map[string]any{
"model": "simulated-terminal", "hardware_revision": "sim-rev-a", "version": version,
"expected_size": len(image), "expected_digest": digest, "signature": signature, "signing_key_id": keyID,
})
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", string(releaseBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("prepare release status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var releaseResponse struct {
Data saas.FirmwareRelease `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil {
t.Fatal(err)
}
releaseID = releaseResponse.Data.ID
revokeBody, _ := json.Marshal(map[string]string{"key_id": keyID})
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys/revoke", string(revokeBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
RevokeFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("revoke uploading key status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/upload-session", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
RenewFirmwareUploadHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("renew upload status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/finalize", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
FinalizeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("finalize release status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/pause", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
PauseFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) {
t.Fatalf("pause release status=%d body=%s", recorder.Code, recorder.Body.String())
}
rolloutBody, _ := json.Marshal(map[string]any{"release_id": releaseID, "name": "HTTP固件下发", "target_percentage": 100, "device_ids": []string{deviceID}})
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("start paused release rollout status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/resume", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
ResumeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"READY"`) {
t.Fatalf("resume release status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("start rollout status=%d body=%s", recorder.Code, recorder.Body.String())
}
var rolloutResponse struct {
Data saas.FirmwareRollout `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &rolloutResponse); err != nil {
t.Fatal(err)
}
rolloutID = rolloutResponse.Data.ID
request = authenticatedRequest(http.MethodGet, "/api/v1/firmware-releases/"+releaseID+"/impact", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
GetFirmwareReleaseImpactHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("get release impact status=%d body=%s", recorder.Code, recorder.Body.String())
}
var impactResponse struct {
Data saas.FirmwareReleaseImpact `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &impactResponse); err != nil || impactResponse.Data.ReleaseStatus != "READY" || impactResponse.Data.Rollouts.Running != 1 || impactResponse.Data.Commands.Queued != 1 {
t.Fatalf("release impact response=%+v err=%v", impactResponse, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/retire", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "FIRMWARE_RELEASE_IN_USE") {
t.Fatalf("retire running release status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/pause", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
PauseFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) {
t.Fatalf("pause running release status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/firmware-releases/"+releaseID+"/impact", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
GetFirmwareReleaseImpactHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("get paused release impact status=%d body=%s", recorder.Code, recorder.Body.String())
}
if err := json.Unmarshal(recorder.Body.Bytes(), &impactResponse); err != nil || impactResponse.Data.ReleaseStatus != "PAUSED" || impactResponse.Data.Rollouts.Paused != 1 || impactResponse.Data.Commands.CancelledBeforeDelivery != 1 {
t.Fatalf("paused release impact response=%+v err=%v", impactResponse, err)
}
rollout, err := service.GetFirmwareRollout(ctx, principal, rolloutID)
if err != nil || rollout.Status != "PAUSED" {
t.Fatalf("release pause did not pause rollout=%+v err=%v", rollout, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/resume", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
ResumeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("resume after rollout pause status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/retire", "", principal)
request.SetPathValue("id", releaseID)
recorder = httptest.NewRecorder()
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"RETIRED"`) {
t.Fatalf("retire verified release status=%d body=%s", recorder.Code, recorder.Body.String())
}
retireBody, _ := json.Marshal(map[string]any{
"model": "simulated-terminal", "hardware_revision": "sim-rev-a", "version": version + "-retire",
"expected_size": len(image), "expected_digest": digest, "signature": signature, "signing_key_id": keyID,
})
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", string(retireBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("prepare retiring release status=%d body=%s", recorder.Code, recorder.Body.String())
}
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil {
t.Fatal(err)
}
retiredReleaseID = releaseResponse.Data.ID
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+retiredReleaseID+"/retire", "", principal)
request.SetPathValue("id", retiredReleaseID)
recorder = httptest.NewRecorder()
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("retire release status=%d body=%s", recorder.Code, recorder.Body.String())
}
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil || releaseResponse.Data.Status != "RETIRED" {
t.Fatalf("retired response=%+v err=%v", releaseResponse, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys/revoke", string(revokeBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
RevokeFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("revoke signing key status=%d body=%s", recorder.Code, recorder.Body.String())
}
var keyResponse struct {
Data saas.FirmwareSigningKey `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &keyResponse); err != nil || keyResponse.Data.Status != "REVOKED" {
t.Fatalf("revoked key response=%+v err=%v", keyResponse, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("rollout with revoked key status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+216
View File
@@ -0,0 +1,216 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/media"
)
func GrantRecordingAccessHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var input struct {
GranteeUserID string `json:"grantee_user_id"`
Permission string `json:"permission"`
Purpose string `json:"purpose"`
ExpiresAt time.Time `json:"expires_at"`
}
if !decodeWebJSON(w, r, &input, "录音访问授权参数不正确") {
return
}
if !validUUID(input.GranteeUserID) || strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() {
writeValidation(w, r, "录音访问授权参数不正确", FieldError{Field: "grant", Message: "被授权用户、权限、用途和到期时间均为必填"})
return
}
item, err := service.GrantRecordingAccess(r.Context(), principal, r.PathValue("id"), input.GranteeUserID, input.Permission, input.Purpose, RequestID(r.Context()), input.ExpiresAt)
if err != nil {
writeMediaError(w, r, err)
return
}
WriteJSON(w, r, http.StatusCreated, item)
})
}
func ListRecordingAccessGrantsHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
items, err := service.ListRecordingAccessGrants(r.Context(), principal, r.PathValue("id"))
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func RevokeRecordingAccessHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.RevokeRecordingAccess(r.Context(), principal, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeMediaError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, item)
})
}
func GetConversationMediaHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.GetConversationMedia(r.Context(), principal, r.PathValue("id"))
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func CreateMediaAccessHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var input struct {
Action string `json:"action"`
Purpose string `json:"purpose"`
}
if !decodeWebJSON(w, r, &input, "录音访问参数不正确") {
return
}
item, err := service.CreateMediaAccess(r.Context(), principal, r.PathValue("id"), input.Action, input.Purpose, RequestID(r.Context()))
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func PrepareMediaUploadBatchHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(idempotencyKey) < 16 || len(idempotencyKey) > 128 {
writeValidation(w, r, "录音上传批次参数不正确", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128个字符"})
return
}
var input struct {
Segments []media.SegmentDeclaration `json:"segments"`
}
if !decodeWebJSON(w, r, &input, "录音上传批次参数不正确") {
return
}
item, err := service.PrepareUploadBatch(r.Context(), identity, r.PathValue("id"), idempotencyKey, RequestID(r.Context()), input.Segments)
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusCreated, item)
})
}
func CompleteMediaUploadBatchHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
item, err := service.CompleteUploadBatch(r.Context(), identity, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusAccepted, item)
})
}
func GetDeviceMediaUploadBatchHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
item, err := service.GetUploadBatch(r.Context(), identity, r.PathValue("id"), false)
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func AcknowledgeLocalMediaDeleteHandler(service *media.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, ok := deviceauth.FromContext(r.Context())
if !ok {
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
return
}
var input struct {
Status string `json:"status"`
ErrorCode string `json:"error_code"`
}
if !decodeWebJSON(w, r, &input, "本地录音删除回执参数不正确") {
return
}
item, err := service.AcknowledgeLocalDelete(r.Context(), identity, r.PathValue("id"), input.Status, input.ErrorCode, RequestID(r.Context()))
if err != nil {
writeMediaError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func writeMediaError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, media.ErrRecordingAccessDenied):
WriteError(w, r, http.StatusForbidden, "RECORDING_ACCESS_DENIED", "当前账号没有该录音的播放或下载授权")
case errors.Is(err, media.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "录音上传批次不存在")
case errors.Is(err, media.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "UPLOAD_NOT_ALLOWED", "当前授权、通话状态或设备归属不允许上传录音")
case errors.Is(err, media.ErrInvalidDeclaration):
writeValidation(w, r, "录音或授权参数不正确", FieldError{Field: "request", Message: "轨道、序号、编码、时长、大小、SHA-256、权限或用途不符合要求"})
case errors.Is(err, media.ErrExpired):
WriteError(w, r, http.StatusGone, "UPLOAD_BATCH_EXPIRED", "录音上传批次已经过期")
case errors.Is(err, media.ErrConflict):
WriteError(w, r, http.StatusConflict, "RESOURCE_STATE_CONFLICT", "录音分片清单与已有数据冲突")
case errors.Is(err, media.ErrLocalDeleteTooEarly):
WriteError(w, r, http.StatusConflict, "LOCAL_DELETE_NOT_DUE", "尚未到达设备本地录音的允许删除时间")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "录音上传处理失败")
}
}
+112
View File
@@ -0,0 +1,112 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"github.com/google/uuid"
)
func TestMediaWebHandlersRejectInvalidRequestsBeforeService(t *testing.T) {
principal := identity.Principal{Issuer: "issuer", Subject: "subject"}
resourceID := uuid.NewString()
tests := []struct {
name string
handler http.Handler
request *http.Request
status int
code string
}{
{"grant unauthenticated", GrantRecordingAccessHandler(nil), httptest.NewRequest(http.MethodPost, "/api/v1/conversations/"+resourceID+"/recording-grants", nil), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"grant invalid resource", GrantRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/conversations/bad/recording-grants", `{}`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"grant malformed body", GrantRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/conversations/"+resourceID+"/recording-grants", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"grant missing fields", GrantRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/conversations/"+resourceID+"/recording-grants", `{}`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"list unauthenticated", ListRecordingAccessGrantsHandler(nil), httptest.NewRequest(http.MethodGet, "/api/v1/conversations/"+resourceID+"/recording-grants", nil), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"revoke invalid resource", RevokeRecordingAccessHandler(nil), authenticatedRequest(http.MethodDelete, "/api/v1/recording-grants/bad", "", principal), http.StatusBadRequest, "VALIDATION_FAILED"},
{"conversation unauthenticated", GetConversationMediaHandler(nil), httptest.NewRequest(http.MethodGet, "/api/v1/conversations/"+resourceID+"/media", nil), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"access malformed body", CreateMediaAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/media/"+resourceID+":access", `{`, principal), http.StatusBadRequest, "VALIDATION_FAILED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if !strings.Contains(test.name, "invalid resource") && !strings.Contains(test.name, "unauthenticated") {
test.request.SetPathValue("id", resourceID)
} else if strings.Contains(test.name, "invalid resource") {
test.request.SetPathValue("id", "bad")
}
recorder := httptest.NewRecorder()
test.handler.ServeHTTP(recorder, test.request)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestMediaDeviceHandlersRejectInvalidRequestsBeforeService(t *testing.T) {
resourceID := uuid.NewString()
device := deviceauth.Identity{ID: uuid.NewString(), PublicID: uuid.NewString(), TenantID: uuid.NewString(), Status: "registered"}
authenticated := func(method, target, body string) *http.Request {
request := httptest.NewRequest(method, target, strings.NewReader(body))
request.SetPathValue("id", resourceID)
return request.WithContext(deviceauth.WithIdentity(request.Context(), device))
}
tests := []struct {
name string
handler http.Handler
request *http.Request
status int
code string
}{
{"prepare unauthenticated", PrepareMediaUploadBatchHandler(nil), httptest.NewRequest(http.MethodPost, "/device/v2/conversations/"+resourceID+"/media-uploads", nil), http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED"},
{"prepare missing idempotency", PrepareMediaUploadBatchHandler(nil), authenticated(http.MethodPost, "/device/v2/conversations/"+resourceID+"/media-uploads", `{}`), http.StatusBadRequest, "VALIDATION_FAILED"},
{"prepare malformed body", PrepareMediaUploadBatchHandler(nil), authenticated(http.MethodPost, "/device/v2/conversations/"+resourceID+"/media-uploads", `{`), http.StatusBadRequest, "VALIDATION_FAILED"},
{"complete unauthenticated", CompleteMediaUploadBatchHandler(nil), httptest.NewRequest(http.MethodPost, "/device/v2/media-upload-batches/"+resourceID+":complete", nil), http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED"},
{"get unauthenticated", GetDeviceMediaUploadBatchHandler(nil), httptest.NewRequest(http.MethodGet, "/device/v2/media-upload-batches/"+resourceID, nil), http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED"},
{"delete unauthenticated", AcknowledgeLocalMediaDeleteHandler(nil), httptest.NewRequest(http.MethodPost, "/device/v2/media/"+resourceID+":local-delete", nil), http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED"},
{"delete malformed body", AcknowledgeLocalMediaDeleteHandler(nil), authenticated(http.MethodPost, "/device/v2/media/"+resourceID+":local-delete", `{`), http.StatusBadRequest, "VALIDATION_FAILED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if strings.Contains(test.name, "malformed") {
test.request.Header.Set("Idempotency-Key", "http-media-valid-key")
}
recorder := httptest.NewRecorder()
test.handler.ServeHTTP(recorder, test.request)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestWriteMediaErrorStableMappings(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{media.ErrRecordingAccessDenied, http.StatusForbidden, "RECORDING_ACCESS_DENIED"},
{media.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{media.ErrAccessDenied, http.StatusForbidden, "UPLOAD_NOT_ALLOWED"},
{media.ErrInvalidDeclaration, http.StatusBadRequest, "VALIDATION_FAILED"},
{media.ErrExpired, http.StatusGone, "UPLOAD_BATCH_EXPIRED"},
{media.ErrConflict, http.StatusConflict, "RESOURCE_STATE_CONFLICT"},
{media.ErrLocalDeleteTooEarly, http.StatusConflict, "LOCAL_DELETE_NOT_DUE"},
{errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, test := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/media", nil)
writeMediaError(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("error=%v status=%d body=%s", test.err, recorder.Code, recorder.Body.String())
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "voice_platform", Subsystem: "http", Name: "requests_total",
Help: "Total HTTP requests by service, method, route and status.",
}, []string{"service", "method", "route", "status"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "voice_platform", Subsystem: "http", Name: "request_duration_seconds",
Help: "HTTP request duration by service, method and route.",
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"service", "method", "route"})
httpInflight = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "voice_platform", Subsystem: "http", Name: "inflight_requests",
Help: "Current in-flight HTTP requests by service.",
}, []string{"service"})
)
func MetricsHandler() http.Handler { return promhttp.Handler() }
func metricsMiddleware(serviceName string, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
httpInflight.WithLabelValues(serviceName).Inc()
defer httpInflight.WithLabelValues(serviceName).Dec()
recorder := &statusRecorder{ResponseWriter: writer, status: http.StatusOK}
defer func() {
route := request.Pattern
if route == "" {
route = "unmatched"
}
httpRequests.WithLabelValues(serviceName, request.Method, route, strconv.Itoa(recorder.status)).Inc()
httpDuration.WithLabelValues(serviceName, request.Method, route).Observe(time.Since(startedAt).Seconds())
}()
next.ServeHTTP(recorder, request)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(body []byte) (int, error) {
written, err := r.ResponseWriter.Write(body)
r.bytes += written
return written, err
}
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
+232
View File
@@ -0,0 +1,232 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"go.opentelemetry.io/otel/trace"
)
type contextKey string
const requestIDKey contextKey = "request-id"
type RateLimitResult struct {
Allowed bool
Remaining int
RetryAfter time.Duration
}
type RateLimiter interface {
Allow(context.Context, string, string) (RateLimitResult, error)
}
func RequestID(ctx context.Context) string {
value, _ := ctx.Value(requestIDKey).(string)
return value
}
func Middleware(logger *slog.Logger, serviceName string, maxBodyBytes int64, limiter RateLimiter, next http.Handler) http.Handler {
return requestIDMiddleware(
securityHeadersMiddleware(
recoveryMiddleware(logger,
accessLogMiddleware(logger,
metricsMiddleware(serviceName,
rateLimitMiddleware(logger, limiter,
bodyLimitMiddleware(maxBodyBytes, next),
),
),
),
),
),
)
}
func rateLimitMiddleware(logger *slog.Logger, limiter RateLimiter, next http.Handler) http.Handler {
if limiter == nil {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/metrics" || strings.HasPrefix(request.URL.Path, "/health/") {
next.ServeHTTP(writer, request)
return
}
identifier := request.RemoteAddr
if host, _, err := net.SplitHostPort(request.RemoteAddr); err == nil {
identifier = host
}
result, err := limiter.Allow(request.Context(), "network", identifier)
if err != nil {
logger.Warn("rate limiter unavailable; request allowed", "request_id", RequestID(request.Context()), "error", err)
next.ServeHTTP(writer, request)
return
}
writer.Header().Set("X-RateLimit-Remaining", strconv.Itoa(result.Remaining))
if !result.Allowed {
retrySeconds := int64((result.RetryAfter + time.Second - 1) / time.Second)
if retrySeconds < 1 {
retrySeconds = 1
}
writer.Header().Set("Retry-After", strconv.FormatInt(retrySeconds, 10))
WriteError(writer, request, http.StatusTooManyRequests, "RATE_LIMITED", "请求过于频繁,请稍后重试")
return
}
next.ServeHTTP(writer, request)
})
}
// UserRateLimit applies independently keyed limits after the bearer token has
// been verified. Tenant limiting is also applied when the API request carries
// the tenant selection header used by tenant-scoped endpoints.
func UserRateLimit(logger *slog.Logger, limiter RateLimiter, next http.Handler) http.Handler {
if limiter == nil {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := identity.PrincipalFromContext(request.Context())
if !ok {
next.ServeHTTP(writer, request)
return
}
scopes := [][2]string{{"user", principal.Issuer + "\x00" + principal.Subject}}
if tenantID := strings.TrimSpace(request.Header.Get("X-Tenant-Id")); tenantID != "" {
scopes = append(scopes, [2]string{"tenant", tenantID})
}
if !allowScopes(writer, request, logger, limiter, scopes) {
return
}
next.ServeHTTP(writer, request)
})
}
// DeviceRateLimit applies device and tenant buckets only after the signed
// device request has been verified, so neither identifier can be spoofed to
// evade the network-address bucket.
func DeviceRateLimit(logger *slog.Logger, limiter RateLimiter, next http.Handler) http.Handler {
if limiter == nil {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
deviceIdentity, ok := deviceauth.FromContext(request.Context())
if !ok {
next.ServeHTTP(writer, request)
return
}
if !allowScopes(writer, request, logger, limiter, [][2]string{
{"device", deviceIdentity.ID},
{"tenant", deviceIdentity.TenantID},
}) {
return
}
next.ServeHTTP(writer, request)
})
}
func allowScopes(writer http.ResponseWriter, request *http.Request, logger *slog.Logger, limiter RateLimiter, scopes [][2]string) bool {
for _, item := range scopes {
result, err := limiter.Allow(request.Context(), item[0], item[1])
if err != nil {
logger.Warn("rate limiter unavailable; scope allowed", "request_id", RequestID(request.Context()), "scope", item[0], "error", err)
continue
}
writer.Header().Set("X-RateLimit-Remaining-"+strings.Title(item[0]), strconv.Itoa(result.Remaining)) //nolint:staticcheck // fixed protocol header casing
if result.Allowed {
continue
}
retrySeconds := int64((result.RetryAfter + time.Second - 1) / time.Second)
if retrySeconds < 1 {
retrySeconds = 1
}
writer.Header().Set("Retry-After", strconv.FormatInt(retrySeconds, 10))
writer.Header().Set("X-RateLimit-Scope", item[0])
WriteError(writer, request, http.StatusTooManyRequests, "RATE_LIMITED", "请求过于频繁,请稍后重试")
return false
}
return true
}
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := strings.TrimSpace(request.Header.Get("X-Request-Id"))
if requestID == "" || len(requestID) > 128 {
requestID = newRequestID()
}
writer.Header().Set("X-Request-Id", requestID)
ctx := context.WithValue(request.Context(), requestIDKey, requestID)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(writer, request)
})
}
func recoveryMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("request panic", "request_id", RequestID(request.Context()), "panic", recovered, "stack", string(debug.Stack()))
WriteError(writer, request, http.StatusInternalServerError, "INTERNAL_ERROR", "服务暂时不可用")
}
}()
next.ServeHTTP(writer, request)
})
}
func accessLogMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
defer func() {
spanContext := trace.SpanContextFromContext(request.Context())
attributes := []any{
"request_id", RequestID(request.Context()),
"method", request.Method,
"route", request.Pattern,
"duration_ms", time.Since(startedAt).Milliseconds(),
}
if spanContext.IsValid() {
attributes = append(attributes,
"trace_id", spanContext.TraceID().String(),
"span_id", spanContext.SpanID().String(),
)
}
logger.Info("http request", attributes...)
}()
next.ServeHTTP(writer, request)
})
}
func bodyLimitMiddleware(maxBodyBytes int64, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.ContentLength > maxBodyBytes {
WriteError(writer, request, http.StatusRequestEntityTooLarge, "REQUEST_BODY_TOO_LARGE", "请求正文超出允许大小")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, maxBodyBytes)
next.ServeHTTP(writer, request)
})
}
func newRequestID() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return time.Now().UTC().Format("20060102T150405.000000000")
}
return hex.EncodeToString(bytes)
}
+110
View File
@@ -0,0 +1,110 @@
package httpapi
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
)
type fakeRateLimiter struct {
result RateLimitResult
err error
calls int
scopes []string
}
func (f *fakeRateLimiter) Allow(_ context.Context, scope, identifier string) (RateLimitResult, error) {
f.calls++
f.scopes = append(f.scopes, scope+":"+identifier)
return f.result, f.err
}
func TestRateLimitRejectsBusinessRequest(t *testing.T) {
limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: false, RetryAfter: 1500 * time.Millisecond}}
handler := Middleware(slog.Default(), "test", 1024, limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("downstream handler must not be called")
}))
request := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
request.RemoteAddr = "192.0.2.1:1234"
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "2" {
t.Fatalf("status = %d, retry-after = %q", recorder.Code, recorder.Header().Get("Retry-After"))
}
if !strings.Contains(recorder.Body.String(), "RATE_LIMITED") {
t.Fatalf("unexpected body: %s", recorder.Body.String())
}
}
func TestUserRateLimitUsesVerifiedUserAndSelectedTenant(t *testing.T) {
limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: true, Remaining: 7}}
called := false
handler := UserRateLimit(slog.Default(), limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
request := httptest.NewRequest(http.MethodGet, "/api/v1/households", nil)
request.Header.Set("X-Tenant-Id", "tenant-public-id")
request = request.WithContext(identity.WithPrincipal(request.Context(), identity.Principal{Issuer: "issuer", Subject: "user-subject"}))
handler.ServeHTTP(httptest.NewRecorder(), request)
if !called || strings.Join(limiter.scopes, ",") != "user:issuer\x00user-subject,tenant:tenant-public-id" {
t.Fatalf("called=%t scopes=%q", called, limiter.scopes)
}
}
func TestDeviceRateLimitRejectsVerifiedDeviceScopeBeforeTenant(t *testing.T) {
limiter := &fakeRateLimiter{result: RateLimitResult{Allowed: false, RetryAfter: time.Second}}
handler := DeviceRateLimit(slog.Default(), limiter, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("downstream handler must not be called")
}))
request := httptest.NewRequest(http.MethodGet, "/device/v2/config", nil)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{ID: "device-internal", TenantID: "tenant-internal"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("X-RateLimit-Scope") != "device" {
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
}
if strings.Join(limiter.scopes, ",") != "device:device-internal" {
t.Fatalf("scopes=%q", limiter.scopes)
}
}
func TestRateLimitSkipsHealthAndFailsOpen(t *testing.T) {
limiter := &fakeRateLimiter{err: errors.New("redis unavailable")}
downstreamCalls := 0
handler := Middleware(slog.Default(), "test", 1024, limiter, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
downstreamCalls++
writer.WriteHeader(http.StatusNoContent)
}))
for _, path := range []string{"/health/live", "/api/v1/items"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusNoContent {
t.Fatalf("path %s status = %d", path, recorder.Code)
}
}
if downstreamCalls != 2 || limiter.calls != 1 {
t.Fatalf("downstream calls = %d, limiter calls = %d", downstreamCalls, limiter.calls)
}
}
func TestBodyLimitRejectsKnownOversizedRequestBeforeHandler(t *testing.T) {
called := false
handler := Middleware(slog.Default(), "test", 16, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
request := httptest.NewRequest(http.MethodPost, "/api/v1/items", strings.NewReader(strings.Repeat("x", 17)))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusRequestEntityTooLarge || called {
t.Fatalf("status=%d called=%t body=%s", recorder.Code, called, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "REQUEST_BODY_TOO_LARGE") {
t.Fatalf("unexpected body: %s", recorder.Body.String())
}
}
+169
View File
@@ -0,0 +1,169 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/opsaccess"
)
func CreateOpsSensitiveAccessRequestHandler(service *opsaccess.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input struct {
ConversationID string `json:"conversation_id"`
Capabilities []string `json:"capabilities"`
Reason string `json:"reason"`
ExpiresAt time.Time `json:"expires_at"`
}
if !decodeWebJSON(w, r, &input, "临时敏感数据访问申请参数不正确") {
return
}
item, created, err := service.CreateRequest(r.Context(), principal, tenantID, input.ConversationID, input.Capabilities, input.Reason, input.ExpiresAt, key, RequestID(r.Context()))
if err != nil {
writeOpsAccessError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsSensitiveAccessRequestsHandler(service *opsaccess.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListRequests(r.Context(), principal, tenantID)
if err != nil {
writeOpsAccessError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func ReviewOpsSensitiveAccessRequestHandler(service *opsaccess.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var input struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &input, "临时访问审批参数不正确") {
return
}
item, created, err := service.ReviewRequest(r.Context(), principal, tenantID, r.PathValue("id"), input.Decision, input.Reason, key, RequestID(r.Context()))
if err != nil {
writeOpsAccessError(w, r, err)
return
}
if !created {
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func GetOpsSensitiveConversationHandler(service *opsaccess.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
grantID := strings.TrimSpace(r.Header.Get("X-Ops-Access-Grant"))
if grantID == "" {
writeValidation(w, r, "临时访问凭据缺失", FieldError{Field: "X-Ops-Access-Grant", Message: "必须提供已批准且未过期的临时访问申请编号"})
return
}
item, err := service.GetSensitiveConversation(r.Context(), principal, tenantID, r.PathValue("id"), grantID, RequestID(r.Context()))
if err != nil {
writeOpsAccessError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func CreateOpsSensitiveRecordingAccessHandler(service *opsaccess.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
var input struct {
GrantID string `json:"grant_id"`
}
if !decodeWebJSON(w, r, &input, "录音临时访问参数不正确") {
return
}
if strings.TrimSpace(input.GrantID) == "" {
writeValidation(w, r, "录音临时访问参数不正确", FieldError{Field: "grant_id", Message: "必须提供已批准且未过期的临时访问申请编号"})
return
}
item, err := service.CreateRecordingAccess(r.Context(), principal, tenantID, r.PathValue("id"), input.GrantID, RequestID(r.Context()))
if err != nil {
writeOpsAccessError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusCreated, item)
})
}
func writeOpsAccessError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, opsaccess.ErrInvalid):
writeValidation(w, r, "临时敏感数据访问参数不正确", FieldError{Field: "temporary_access", Message: "通话、能力、原因或有效时间不符合要求"})
case errors.Is(err, opsaccess.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "TEMPORARY_ACCESS_DENIED", "临时访问未批准、已过期、能力不匹配或不属于当前申请人")
case errors.Is(err, opsaccess.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "指定临时访问申请、通话或录音不存在")
case errors.Is(err, opsaccess.ErrConflict):
WriteError(w, r, http.StatusConflict, "TEMPORARY_ACCESS_STATE_CONFLICT", "已有未结束申请、申请人尝试自批或当前状态不允许操作")
case errors.Is(err, idempotency.ErrKeyConflict):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已被不同请求使用")
case errors.Is(err, idempotency.ErrInProgress):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS", "相同请求正在处理中")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "临时敏感数据访问处理失败")
}
}
@@ -0,0 +1,220 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsaccess"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type httpOpsAccessStore struct{}
func (httpOpsAccessStore) PresignMediaDownload(_ context.Context, objectRef string, _ time.Duration, _ string, _ string, _ bool) (string, error) {
return "https://objects.example.test/" + objectRef, nil
}
func TestOpsAccessHandlersCompleteTwoPersonAccessFlow(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := opsaccess.NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository(), httpOpsAccessStore{}, 5*time.Minute)
if err != nil {
t.Fatal(err)
}
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
consentSnapshotID, contextSnapshotID := uuid.NewString(), uuid.NewString()
turnID, segmentID, segmentPublicID, consentID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
keyPrefix := "http-ops-access-" + uuid.NewString()
createKey, reviewKey := keyPrefix+"-create", keyPrefix+"-review"
requestPrefix := keyPrefix + "-request-"
reason := "HTTP双人审批后临时排查转写和录音不一致-" + uuid.NewString()
defer func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
_, _ = pool.Exec(context.Background(), `DELETE FROM ops_sensitive_access_log WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(context.Background(), `DELETE FROM ops_sensitive_access_request WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(context.Background(), `DELETE FROM media_segment WHERE id=$1::uuid`, segmentID)
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation_turn WHERE id=$1::uuid`, turnID)
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
_, _ = pool.Exec(context.Background(), `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextSnapshotID)
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID)
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_record WHERE id=$1::uuid`, consentID)
_, _ = pool.Exec(context.Background(), `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{createKey, reviewKey})
}()
if _, err = pool.Exec(ctx, `INSERT INTO consent_record(id,tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'raw_audio_storage',$3,repeat('a',64),'10000000-0000-0000-0000-000000000101'::uuid)`, consentID, tenantID, keyPrefix); err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,$3,'{"transcript_storage":true,"raw_audio_storage":true}'::jsonb)`, consentSnapshotID, tenantID, time.Now().UnixNano()%2_000_000_000); err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,$3::uuid,$4,'s2s_hybrid',repeat('b',64))`, contextSnapshotID, tenantID, consentSnapshotID, keyPrefix); err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,ended_at) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,$4::uuid,$5::uuid,$6,'s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '5 minutes',now())`, conversationID, conversationPublicID, tenantID, consentSnapshotID, contextSnapshotID, keyPrefix); err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `INSERT INTO conversation_turn(id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,$3::uuid,$4,1,'subject',now()-interval '2 seconds',now()-interval '1 second','仅在批准后可见的HTTP测试字幕','provider')`, turnID, tenantID, conversationID, keyPrefix); err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `INSERT INTO media_segment(id,public_id,tenant_id,conversation_id,device_id,track,sequence_no,codec,content_type,duration_ms,expected_size,expected_digest,object_ref,status,verified_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'near',0,'pcm16','audio/wav',1000,44,repeat('c',64),$5,'VERIFIED',now())`, segmentID, segmentPublicID, tenantID, conversationID, keyPrefix+"/audio.wav"); err != nil {
t.Fatal(err)
}
requestWithHeaders := func(method, target, body string, principal identity.Principal, idempotencyKey, requestID string) *http.Request {
request := authenticatedRequest(method, target, body, principal)
request.Header.Set("X-Tenant-Id", tenantPublicID)
if idempotencyKey != "" {
request.Header.Set("Idempotency-Key", idempotencyKey)
}
return request.WithContext(context.WithValue(request.Context(), requestIDKey, requestPrefix+requestID))
}
expiresAt := time.Now().UTC().Add(time.Hour)
createBody, _ := json.Marshal(map[string]any{"conversation_id": conversationPublicID, "capabilities": []string{opsaccess.CapabilityTranscript, opsaccess.CapabilityRecording}, "reason": reason, "expires_at": expiresAt})
request := requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests", string(createBody), requester, createKey, "create")
recorder := httptest.NewRecorder()
CreateOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("create status=%d body=%s", recorder.Code, recorder.Body.String())
}
var createdResponse struct {
Data opsaccess.Request `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &createdResponse); err != nil {
t.Fatal(err)
}
created := createdResponse.Data
if created.ID == "" || created.Status != "PENDING" {
t.Fatalf("unexpected created request: %+v", created)
}
request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests", string(createBody), requester, createKey, "create-replay")
recorder = httptest.NewRecorder()
CreateOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
t.Fatalf("create replay status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = requestWithHeaders(http.MethodGet, "/api/v1/ops/sensitive-access-requests", "", approver, "", "list")
recorder = httptest.NewRecorder()
ListOpsSensitiveAccessRequestsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), created.ID) || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("list status=%d body=%s", recorder.Code, recorder.Body.String())
}
reviewBody := `{"decision":"APPROVE","reason":"另一名运营人员完成最小权限复核"}`
request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-access-requests/"+created.ID+"/review", reviewBody, approver, reviewKey, "review")
request.SetPathValue("id", created.ID)
recorder = httptest.NewRecorder()
ReviewOpsSensitiveAccessRequestHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"APPROVED"`) {
t.Fatalf("review status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = requestWithHeaders(http.MethodGet, "/api/v1/ops/sensitive-conversations/"+conversationPublicID, "", requester, "", "conversation")
request.SetPathValue("id", conversationPublicID)
request.Header.Set("X-Ops-Access-Grant", created.ID)
recorder = httptest.NewRecorder()
GetOpsSensitiveConversationHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "仅在批准后可见的HTTP测试字幕") || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("conversation status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = requestWithHeaders(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+segmentPublicID+":access", `{"grant_id":"`+created.ID+`"}`, requester, "", "recording")
request.SetPathValue("id", segmentPublicID)
recorder = httptest.NewRecorder()
CreateOpsSensitiveRecordingAccessHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || !strings.Contains(recorder.Body.String(), "https://objects.example.test/") || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("recording status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestOpsAccessHandlersRejectInvalidRequestsBeforeService(t *testing.T) {
principal := identity.Principal{Issuer: "issuer", Subject: "subject"}
tenantID, resourceID := uuid.NewString(), uuid.NewString()
tests := []struct {
name string
handler http.Handler
request *http.Request
}{
{"create unauthenticated", CreateOpsSensitiveAccessRequestHandler(nil), httptest.NewRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", nil)},
{"create missing tenant", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{}`, principal)},
{"create missing idempotency", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{}`, principal)},
{"create malformed body", CreateOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests", `{`, principal)},
{"list missing tenant", ListOpsSensitiveAccessRequestsHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/sensitive-access-requests", "", principal)},
{"review malformed body", ReviewOpsSensitiveAccessRequestHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-access-requests/"+resourceID+"/review", `{`, principal)},
{"conversation missing grant", GetOpsSensitiveConversationHandler(nil), authenticatedRequest(http.MethodGet, "/api/v1/ops/sensitive-conversations/"+resourceID, "", principal)},
{"recording malformed body", CreateOpsSensitiveRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+resourceID+":access", `{`, principal)},
{"recording missing grant", CreateOpsSensitiveRecordingAccessHandler(nil), authenticatedRequest(http.MethodPost, "/api/v1/ops/sensitive-recordings/"+resourceID+":access", `{}`, principal)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if !strings.Contains(test.name, "missing tenant") && !strings.Contains(test.name, "unauthenticated") {
test.request.Header.Set("X-Tenant-Id", tenantID)
}
if strings.Contains(test.name, "malformed") {
test.request.Header.Set("Idempotency-Key", "http-ops-access-invalid")
}
if strings.Contains(test.name, "review") || strings.Contains(test.name, "conversation") || strings.Contains(test.name, "recording") {
test.request.SetPathValue("id", resourceID)
}
recorder := httptest.NewRecorder()
test.handler.ServeHTTP(recorder, test.request)
if recorder.Code != http.StatusBadRequest && recorder.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") && !strings.Contains(recorder.Body.String(), "AUTHENTICATION_REQUIRED") {
t.Fatalf("unexpected body=%s", recorder.Body.String())
}
})
}
}
func TestWriteOpsAccessErrorStableMappings(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{opsaccess.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{opsaccess.ErrAccessDenied, http.StatusForbidden, "TEMPORARY_ACCESS_DENIED"},
{opsaccess.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{opsaccess.ErrConflict, http.StatusConflict, "TEMPORARY_ACCESS_STATE_CONFLICT"},
{idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{idempotency.ErrInProgress, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS"},
{errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, test := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/ops/sensitive-access-requests", nil)
writeOpsAccessError(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("error=%v status=%d body=%s", test.err, recorder.Code, recorder.Body.String())
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsconfig"
)
func CreateOpsConfigVersionHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if !validUUID(tenantID) || len(key) < 16 || len(key) > 128 {
writeValidation(w, r, "运营配置请求参数不正确", FieldError{Field: "headers", Message: "X-Tenant-Id和16至128字符的Idempotency-Key均为必填"})
return
}
var body struct {
ConfigKey string `json:"config_key"`
DisplayName string `json:"display_name"`
ConfigKind string `json:"config_kind"`
Payload map[string]any `json:"payload"`
SecretRefs map[string]string `json:"secret_refs"`
ChangeSummary string `json:"change_summary"`
}
if !decodeWebJSON(w, r, &body, "运营配置草稿参数不正确") {
return
}
item, created, err := service.Create(r.Context(), principal, key, RequestID(r.Context()), opsconfig.CreateInput{TenantID: tenantID, ConfigKey: body.ConfigKey, DisplayName: body.DisplayName, ConfigKind: body.ConfigKind, Payload: body.Payload, SecretRefs: body.SecretRefs, ChangeSummary: body.ChangeSummary})
if err != nil {
writeOpsConfigError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsConfigVersionsHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运营配置查询参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return
}
items, err := service.List(r.Context(), principal, tenantID, r.URL.Query().Get("config_key"))
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func SubmitOpsConfigVersionHandler(service *opsconfig.Service) http.Handler {
return opsConfigTransitionHandler(func(r *http.Request, principalInput opsPrincipalInput, expected int64) (opsconfig.Version, error) {
return service.Submit(r.Context(), principalInput.principal, principalInput.tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
})
}
func PublishOpsConfigVersionHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运营配置发布参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var body opsconfig.PublishInput
if !decodeWebJSON(w, r, &body, "配置发布参数不正确") {
return
}
item, err := service.Publish(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), body, expected)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func RollbackOpsConfigVersionHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运营配置回滚参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(key) < 16 || len(key) > 128 {
writeValidation(w, r, "运营配置回滚参数不正确", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
return
}
var body opsconfig.PublishInput
if !decodeWebJSON(w, r, &body, "配置回滚参数不正确") {
return
}
item, created, err := service.Rollback(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
WriteJSON(w, r, status, item)
})
}
type opsPrincipalInput struct {
principal interfacePrincipal
tenantID string
}
// interfacePrincipal is aliased below through a concrete adapter to keep the transition
// helper small without weakening the service's identity type.
type interfacePrincipal = identity.Principal
func opsConfigTransitionHandler(transition func(*http.Request, opsPrincipalInput, int64) (opsconfig.Version, error)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运营配置状态变更参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
item, err := transition(r, opsPrincipalInput{principal: principal, tenantID: tenantID}, expected)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func writeOpsConfigError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, opsconfig.ErrInvalid):
writeValidation(w, r, "运营配置参数或结构校验未通过", FieldError{Field: "config", Message: "字段、资源引用、密钥引用或配置结构不符合runtime-config-v1"})
case errors.Is(err, opsconfig.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "当前账号没有所需的内部运营权限")
case errors.Is(err, opsconfig.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "运营配置版本不存在")
case errors.Is(err, opsconfig.ErrVersionConflict):
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "配置版本已变化,请重新读取后再操作")
case errors.Is(err, opsconfig.ErrSelfApproval):
WriteError(w, r, http.StatusConflict, "SELF_APPROVAL_FORBIDDEN", "配置创建人不能审批并发布自己的变更")
case errors.Is(err, opsconfig.ErrStateConflict):
WriteError(w, r, http.StatusConflict, "CONFIG_STATE_CONFLICT", "当前配置状态不允许执行该操作")
case errors.Is(err, opsconfig.ErrPublishGate):
WriteError(w, r, http.StatusConflict, "CONFIG_PUBLISH_GATE_FAILED", "配置缺少有效评测,或组件版本、资源与密钥引用未通过有效性检查")
case errors.Is(err, idempotency.ErrKeyConflict):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已被不同请求使用")
case errors.Is(err, idempotency.ErrInProgress):
WriteError(w, r, http.StatusConflict, "IDEMPOTENCY_IN_PROGRESS", "相同请求正在处理中")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "运营配置处理失败")
}
}
@@ -0,0 +1,200 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsconfig"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestOpsConfigHandlersPublishAndRollback(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := opsconfig.NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
tenantID := "10000000-0000-0000-0000-000000000010"
configKey := "http.integration.routing." + uuid.NewString()
createKey, evaluationKey, rollbackKey := "http-ops-create-"+uuid.NewString(), "http-ops-eval-"+uuid.NewString(), "http-ops-rollback-"+uuid.NewString()
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'http-ops-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{createKey, evaluationKey, rollbackKey})
_, _ = pool.Exec(ctx, `DELETE FROM runtime_config_rollout WHERE config_key=$1`, configKey)
_, _ = pool.Exec(ctx, `DELETE FROM ops_config_evaluation WHERE config_version_id IN (SELECT id FROM runtime_config_version WHERE config_key=$1)`, configKey)
_, _ = pool.Exec(ctx, `DELETE FROM runtime_config_version WHERE config_key=$1`, configKey)
}()
withRequestID := func(request *http.Request, id string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), requestIDKey, id))
}
setOpsHeaders := func(request *http.Request, key string) {
request.Header.Set("X-Tenant-Id", tenantID)
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
}
configBody, _ := json.Marshal(map[string]any{"config_key": configKey, "display_name": "HTTP路由配置", "config_kind": "ROUTING", "payload": map[string]any{"default_engine_mode": "s2s_hybrid", "routes": []any{}}, "secret_refs": map[string]string{}, "change_summary": "HTTP配置状态机"})
request := withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions", string(configBody), admin), "http-ops-create")
setOpsHeaders(request, createKey)
recorder := httptest.NewRecorder()
CreateOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("create config status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var configResponse struct {
Data opsconfig.Version `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &configResponse); err != nil {
t.Fatal(err)
}
version := configResponse.Data
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions", string(configBody), admin), "http-ops-create-replay")
setOpsHeaders(request, createKey)
recorder = httptest.NewRecorder()
CreateOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
t.Fatalf("replay config status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-versions?config_key="+configKey, "", approver)
setOpsHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsConfigVersionsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), version.ID) {
t.Fatalf("list config status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/submit", "", admin), "http-ops-submit")
request.SetPathValue("id", version.ID)
setOpsHeaders(request, "")
recorder = httptest.NewRecorder()
SubmitOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing submit ETag status=%d body=%s", recorder.Code, recorder.Body.String())
}
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
SubmitOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("submit config status=%d body=%s", recorder.Code, recorder.Body.String())
}
publishBody := `{"reason":"HTTP评测通过发布","rollout":{"scope_type":"TENANT","rollout_percent":100,"max_concurrent_calls":10}}`
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/publish", publishBody, approver), "http-ops-publish-no-eval")
request.SetPathValue("id", version.ID)
setOpsHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(2))
recorder = httptest.NewRecorder()
PublishOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "CONFIG_PUBLISH_GATE_FAILED") {
t.Fatalf("publish gate status=%d body=%s", recorder.Code, recorder.Body.String())
}
evaluationBody := `{"suite_version":"http-suite-v1","result":"PASSED","metrics":{"cases":8,"passed":8},"report_ref":"integration://http-ops/report"}`
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/evaluations", evaluationBody, admin), "http-ops-evaluation")
request.SetPathValue("id", version.ID)
setOpsHeaders(request, evaluationKey)
recorder = httptest.NewRecorder()
CreateOpsConfigEvaluationHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("create evaluation status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-versions/"+version.ID+"/evaluations", "", approver)
request.SetPathValue("id", version.ID)
setOpsHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsConfigEvaluationsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "http-suite-v1") {
t.Fatalf("list evaluations status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/publish", publishBody, approver), "http-ops-publish")
request.SetPathValue("id", version.ID)
setOpsHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(2))
recorder = httptest.NewRecorder()
PublishOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(3) {
t.Fatalf("publish config status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/config-rollouts?config_key="+configKey, "", approver)
setOpsHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsConfigRolloutsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), version.ID) {
t.Fatalf("list rollouts status=%d body=%s", recorder.Code, recorder.Body.String())
}
var rolloutResponse struct {
Data struct {
Items []opsconfig.Rollout `json:"items"`
} `json:"data"`
}
if err = json.Unmarshal(recorder.Body.Bytes(), &rolloutResponse); err != nil || len(rolloutResponse.Data.Items) != 1 {
t.Fatalf("decode rollouts=%+v err=%v", rolloutResponse, err)
}
activeRollout := rolloutResponse.Data.Items[0]
pauseBody := `{"reason":"HTTP纵向冒烟暂停"}`
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/pause", pauseBody, approver), "http-ops-rollout-pause")
request.SetPathValue("id", activeRollout.ID)
setOpsHeaders(request, "")
recorder = httptest.NewRecorder()
PauseOpsConfigRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing rollout ETag status=%d body=%s", recorder.Code, recorder.Body.String())
}
request.Header.Set("If-Match", concurrency.FormatETag(activeRollout.RowVersion))
recorder = httptest.NewRecorder()
PauseOpsConfigRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) {
t.Fatalf("pause rollout status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
resumeBody := `{"reason":"HTTP纵向冒烟恢复"}`
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/resume", resumeBody, approver), "http-ops-rollout-resume-stale")
request.SetPathValue("id", activeRollout.ID)
setOpsHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
ResumeOpsConfigRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "VERSION_CONFLICT") {
t.Fatalf("stale rollout resume status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/config-rollouts/"+activeRollout.ID+"/resume", resumeBody, approver), "http-ops-rollout-resume")
request.SetPathValue("id", activeRollout.ID)
setOpsHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(2))
recorder = httptest.NewRecorder()
ResumeOpsConfigRolloutHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(3) || !strings.Contains(recorder.Body.String(), `"status":"ACTIVE"`) {
t.Fatalf("resume rollout status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
rollbackBody := `{"reason":"HTTP回滚验证","rollout":{"scope_type":"TENANT","rollout_percent":100,"max_concurrent_calls":10}}`
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/config-versions/"+version.ID+"/rollback", rollbackBody, approver), "http-ops-rollback")
request.SetPathValue("id", version.ID)
setOpsHeaders(request, rollbackKey)
recorder = httptest.NewRecorder()
RollbackOpsConfigVersionHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || !strings.Contains(recorder.Body.String(), `"version_no":2`) {
t.Fatalf("rollback status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+291
View File
@@ -0,0 +1,291 @@
package httpapi
import (
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsconfig"
)
func CreateOpsResourceReferenceHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
ResourceType string `json:"resource_type"`
ReferenceName string `json:"reference_name"`
DisplayName string `json:"display_name"`
VerifiedAt time.Time `json:"verified_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
if !decodeWebJSON(w, r, &body, "运营资源引用参数不正确") {
return
}
item, created, err := service.CreateResourceReference(r.Context(), principal, tenantID, key, RequestID(r.Context()), body.ResourceType, body.ReferenceName, body.DisplayName, body.VerifiedAt, body.ExpiresAt)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsResourceReferencesHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListResourceReferences(r.Context(), principal, tenantID)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func RevokeOpsResourceReferenceHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "资源引用撤销参数不正确") {
return
}
item, err := service.RevokeResourceReference(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), body.Reason, expected)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func CreateOpsConfigEvaluationHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
EvidenceType string `json:"evidence_type"`
SuiteVersion string `json:"suite_version"`
Result string `json:"result"`
Metrics map[string]any `json:"metrics"`
ReportRef string `json:"report_ref"`
}
if !decodeWebJSON(w, r, &body, "离线评测参数不正确") {
return
}
item, created, err := service.CreateEvaluation(r.Context(), principal, tenantID, r.PathValue("id"), key, RequestID(r.Context()), body.EvidenceType, body.SuiteVersion, body.Result, body.ReportRef, body.Metrics)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsConfigEvaluationsHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListEvaluations(r.Context(), principal, tenantID, r.PathValue("id"))
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func CreateOpsDeviceGroupHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, key, ok := opsCreateHeaders(w, r)
if !ok {
return
}
var body struct {
Name string `json:"name"`
Selector map[string]any `json:"selector"`
DeviceIDs []string `json:"device_ids"`
}
if !decodeWebJSON(w, r, &body, "设备分组参数不正确") {
return
}
item, created, err := service.CreateDeviceGroup(r.Context(), principal, tenantID, key, RequestID(r.Context()), body.Name, body.Selector, body.DeviceIDs)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListOpsDeviceGroupsHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListDeviceGroups(r.Context(), principal, tenantID)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func ListOpsConfigRolloutsHandler(service *opsconfig.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
items, err := service.ListRollouts(r.Context(), principal, tenantID, r.URL.Query().Get("config_key"))
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func PauseOpsConfigRolloutHandler(service *opsconfig.Service) http.Handler {
return opsConfigRolloutTransitionHandler(func(r *http.Request, principal identity.Principal, tenantID, reason string, expected int64) (opsconfig.Rollout, error) {
return service.PauseRollout(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), reason, expected)
})
}
func ResumeOpsConfigRolloutHandler(service *opsconfig.Service) http.Handler {
return opsConfigRolloutTransitionHandler(func(r *http.Request, principal identity.Principal, tenantID, reason string, expected int64) (opsconfig.Rollout, error) {
return service.ResumeRollout(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), reason, expected)
})
}
func opsConfigRolloutTransitionHandler(transition func(*http.Request, identity.Principal, string, string, int64) (opsconfig.Rollout, error)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var body struct {
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &body, "配置投放状态变更参数不正确") {
return
}
item, err := transition(r, principal, tenantID, body.Reason, expected)
if err != nil {
writeOpsConfigError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.RowVersion))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func opsTenantHeader(w http.ResponseWriter, r *http.Request) (string, bool) {
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if !validUUID(tenantID) {
writeValidation(w, r, "运营范围参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
return "", false
}
return tenantID, true
}
func opsCreateHeaders(w http.ResponseWriter, r *http.Request) (string, string, bool) {
tenantID, ok := opsTenantHeader(w, r)
if !ok {
return "", "", false
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(key) < 16 || len(key) > 128 {
writeValidation(w, r, "运营写入参数不正确", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
return "", "", false
}
return tenantID, key, true
}
@@ -0,0 +1,165 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/opsconfig"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestOpsGateHandlersResourceReferencesAndDeviceGroups(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := opsconfig.NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
tenantID := "10000000-0000-0000-0000-000000000010"
deviceID := "10000000-0000-0000-0000-000000000511"
resourceKey := "http-ops-gates-resource-" + uuid.NewString()
groupKey := "http-ops-gates-group-" + uuid.NewString()
deniedGroupKey := "http-ops-gates-denied-" + uuid.NewString()
referenceName := "vault://certificates/http-gate-" + uuid.NewString()
groupName := "HTTP灰度设备组-" + uuid.NewString()
requestPrefix := "http-ops-gates-"
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{resourceKey, groupKey, deniedGroupKey})
_, _ = pool.Exec(ctx, `DELETE FROM ops_device_group_member WHERE group_id IN (SELECT id FROM ops_device_group WHERE name=$1)`, groupName)
_, _ = pool.Exec(ctx, `DELETE FROM ops_device_group WHERE name=$1`, groupName)
_, _ = pool.Exec(ctx, `DELETE FROM ops_resource_reference WHERE reference_name=$1`, referenceName)
}()
setHeaders := func(request *http.Request, key string) {
request.Header.Set("X-Tenant-Id", tenantID)
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
}
withRequestID := func(request *http.Request, id string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), requestIDKey, id))
}
invalidTenantRequest := authenticatedRequest(http.MethodGet, "/api/v1/ops/resource-references", "", admin)
invalidTenantRecorder := httptest.NewRecorder()
ListOpsResourceReferencesHandler(service).ServeHTTP(invalidTenantRecorder, invalidTenantRequest)
if invalidTenantRecorder.Code != http.StatusBadRequest || !strings.Contains(invalidTenantRecorder.Body.String(), "VALIDATION_FAILED") {
t.Fatalf("invalid tenant status=%d body=%s", invalidTenantRecorder.Code, invalidTenantRecorder.Body.String())
}
verifiedAt := time.Now().UTC().Add(-time.Hour)
expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour)
resourceBody, _ := json.Marshal(map[string]any{
"resource_type": "CERTIFICATE", "reference_name": referenceName, "display_name": "HTTP集成受控证书",
"verified_at": verifiedAt, "expires_at": expiresAt,
})
request := withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/resource-references", string(resourceBody), admin), requestPrefix+"resource-create")
setHeaders(request, resourceKey)
recorder := httptest.NewRecorder()
CreateOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("create resource status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var resourceResponse struct {
Data opsconfig.ResourceReference `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &resourceResponse); err != nil {
t.Fatal(err)
}
resource := resourceResponse.Data
if resource.Status != "ACTIVE" || resource.RowVersion != 1 || resource.ReferenceName != referenceName {
t.Fatalf("resource=%+v", resource)
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/resource-references", string(resourceBody), admin), requestPrefix+"resource-replay")
setHeaders(request, resourceKey)
recorder = httptest.NewRecorder()
CreateOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" || !strings.Contains(recorder.Body.String(), resource.ID) {
t.Fatalf("resource replay status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/resource-references", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsResourceReferencesHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), referenceName) {
t.Fatalf("list resources status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/resource-references/"+resource.ID+"/revoke", `{"reason":"轮换后撤销旧证书"}`, admin), requestPrefix+"resource-revoke-missing-etag")
request.SetPathValue("id", resource.ID)
setHeaders(request, "")
recorder = httptest.NewRecorder()
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing resource ETag status=%d body=%s", recorder.Code, recorder.Body.String())
}
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) || !strings.Contains(recorder.Body.String(), `"status":"REVOKED"`) {
t.Fatalf("revoke resource status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/resource-references/"+resource.ID+"/revoke", `{"reason":"重复撤销应拒绝"}`, admin), requestPrefix+"resource-revoke-repeat")
request.SetPathValue("id", resource.ID)
setHeaders(request, "")
request.Header.Set("If-Match", concurrency.FormatETag(2))
recorder = httptest.NewRecorder()
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "CONFIG_STATE_CONFLICT") {
t.Fatalf("repeat revoke status=%d body=%s", recorder.Code, recorder.Body.String())
}
groupBody, _ := json.Marshal(map[string]any{"name": groupName, "selector": map[string]any{"purpose": "http-integration"}, "device_ids": []string{deviceID}})
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(groupBody), admin), requestPrefix+"group-create")
setHeaders(request, groupKey)
recorder = httptest.NewRecorder()
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), deviceID) {
t.Fatalf("create group status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(groupBody), admin), requestPrefix+"group-replay")
setHeaders(request, groupKey)
recorder = httptest.NewRecorder()
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
t.Fatalf("group replay status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/device-groups", "", admin)
setHeaders(request, "")
recorder = httptest.NewRecorder()
ListOpsDeviceGroupsHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), groupName) {
t.Fatalf("list groups status=%d body=%s", recorder.Code, recorder.Body.String())
}
deniedBody, _ := json.Marshal(map[string]any{"name": groupName + "-denied", "selector": map[string]any{}, "device_ids": []string{uuid.NewString()}})
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(deniedBody), admin), requestPrefix+"group-denied")
setHeaders(request, deniedGroupKey)
recorder = httptest.NewRecorder()
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "OPS_ACCESS_DENIED") {
t.Fatalf("unknown device group status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+121
View File
@@ -0,0 +1,121 @@
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
}
+103
View File
@@ -0,0 +1,103 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/orchestration"
"data-terminal-cloud/internal/voiceprovider"
)
type fakeProviderCallbackProcessor struct{ calls int }
func (p *fakeProviderCallbackProcessor) Process(context.Context, voiceprovider.CallbackEvent, []byte) (orchestration.ProviderEventResult, error) {
p.calls++
return orchestration.ProviderEventResult{ConversationID: "conversation-1", Status: "ACTIVE"}, nil
}
func TestProviderCallbackRequiresSignatureAndProcessesEvent(t *testing.T) {
secret := []byte("01234567890123456789012345678901")
processor := &fakeProviderCallbackProcessor{}
handler := VoiceProviderCallbackHandler(secret, time.Minute, processor)
event := voiceprovider.CallbackEvent{
Provider: "mock", EventID: "event-1", TaskRef: "task-1", ConversationID: "conversation-1",
EventType: "voice_chat.started", Status: "ACTIVE", OccurredAt: time.Now().UTC(),
}
body, _ := json.Marshal(event)
unsigned := httptest.NewRequest(http.MethodPost, "/callbacks/v1/vertc/voice-chat", strings.NewReader(string(body)))
unsignedRecorder := httptest.NewRecorder()
handler.ServeHTTP(unsignedRecorder, unsigned)
if unsignedRecorder.Code != http.StatusUnauthorized {
t.Fatalf("unsigned status=%d", unsignedRecorder.Code)
}
timestamp := time.Now().UTC().Format(time.RFC3339)
signed := httptest.NewRequest(http.MethodPost, "/callbacks/v1/vertc/voice-chat", strings.NewReader(string(body)))
signed.Header.Set(voiceprovider.HeaderCallbackTimestamp, timestamp)
signed.Header.Set(voiceprovider.HeaderCallbackSignature, voiceprovider.SignCallback(secret, timestamp, body))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, signed)
if recorder.Code != http.StatusOK || processor.calls != 1 || !containsText(recorder.Body.String(), "ACTIVE") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, processor.calls, recorder.Body.String())
}
}
func TestProviderCallbackValidatesUsageMetrics(t *testing.T) {
validMetric := voiceprovider.UsageMetric{UsageCategory: "LLM", MetricCode: "input_tokens", Quantity: "123.456", Unit: "token"}
tests := []struct {
name string
event voiceprovider.CallbackEvent
valid bool
}{
{"non usage without metrics", voiceprovider.CallbackEvent{EventType: "voice_chat.started"}, true},
{"non usage with metrics", voiceprovider.CallbackEvent{EventType: "voice_chat.started", Usage: []voiceprovider.UsageMetric{validMetric}}, false},
{"valid usage", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{validMetric}}, true},
{"wrong status", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "ACTIVE", Usage: []voiceprovider.UsageMetric{validMetric}}, false},
{"empty metrics", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL"}, false},
{"unknown category", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{{UsageCategory: "UNKNOWN", MetricCode: "input_tokens", Quantity: "1", Unit: "token"}}}, false},
{"invalid code", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{{UsageCategory: "LLM", MetricCode: "Input", Quantity: "1", Unit: "token"}}}, false},
{"invalid unit", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{{UsageCategory: "LLM", MetricCode: "input_tokens", Quantity: "1", Unit: "Token"}}}, false},
{"invalid quantity", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{{UsageCategory: "LLM", MetricCode: "input_tokens", Quantity: "01", Unit: "token"}}}, false},
{"duplicate metric", voiceprovider.CallbackEvent{EventType: "voice_chat.usage", Status: "FINAL", Usage: []voiceprovider.UsageMetric{validMetric, validMetric}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if actual := validProviderUsage(test.event); actual != test.valid {
t.Fatalf("valid=%v want=%v event=%+v", actual, test.valid, test.event)
}
})
}
}
func TestProviderCallbackValidatesFinalTurns(t *testing.T) {
now := time.Now().UTC()
validTurn := voiceprovider.FinalTurn{TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: now, EndAt: now.Add(time.Second), FinalText: "测试终稿", ProviderRef: "provider-turn-1"}
tests := []struct {
name string
event voiceprovider.CallbackEvent
valid bool
}{
{"non transcript without turns", voiceprovider.CallbackEvent{EventType: "voice_chat.started"}, true},
{"non transcript with turns", voiceprovider.CallbackEvent{EventType: "voice_chat.started", Turns: []voiceprovider.FinalTurn{validTurn}}, false},
{"valid transcript", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{validTurn}}, true},
{"wrong status", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "ACTIVE", Turns: []voiceprovider.FinalTurn{validTurn}}, false},
{"empty turns", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL"}, false},
{"missing key", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{{SequenceNo: 1, Speaker: "subject", StartAt: now, EndAt: now, FinalText: "text"}}}, false},
{"invalid sequence", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{{TurnKey: "turn", Speaker: "subject", StartAt: now, EndAt: now, FinalText: "text"}}}, false},
{"invalid speaker", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{{TurnKey: "turn", SequenceNo: 1, Speaker: "system", StartAt: now, EndAt: now, FinalText: "text"}}}, false},
{"end before start", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{{TurnKey: "turn", SequenceNo: 1, Speaker: "agent", StartAt: now, EndAt: now.Add(-time.Second), FinalText: "text"}}}, false},
{"duplicate key", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{validTurn, {TurnKey: "turn-1", SequenceNo: 2, Speaker: "agent", StartAt: now, EndAt: now, FinalText: "text"}}}, false},
{"duplicate sequence", voiceprovider.CallbackEvent{EventType: "voice_chat.transcript.final", Status: "FINAL", Turns: []voiceprovider.FinalTurn{validTurn, {TurnKey: "turn-2", SequenceNo: 1, Speaker: "agent", StartAt: now, EndAt: now, FinalText: "text"}}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if actual := validProviderTurns(test.event); actual != test.valid {
t.Fatalf("valid=%v want=%v event=%+v", actual, test.valid, test.event)
}
})
}
}
+55
View File
@@ -0,0 +1,55 @@
package httpapi
import (
"encoding/json"
"net/http"
)
type responseEnvelope struct {
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
FieldErrors []FieldError `json:"field_errors"`
Details map[string]any `json:"details"`
}
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func WriteJSON(writer http.ResponseWriter, request *http.Request, status int, data any) {
write(writer, status, responseEnvelope{RequestID: RequestID(request.Context()), Data: data})
}
func WriteError(writer http.ResponseWriter, request *http.Request, status int, code, message string) {
WriteAPIError(writer, request, status, Error{
Code: code, Message: message, Retryable: false,
FieldErrors: []FieldError{}, Details: map[string]any{},
})
}
func WriteAPIError(writer http.ResponseWriter, request *http.Request, status int, apiError Error) {
if apiError.FieldErrors == nil {
apiError.FieldErrors = []FieldError{}
}
if apiError.Details == nil {
apiError.Details = map[string]any{}
}
write(writer, status, responseEnvelope{
RequestID: RequestID(request.Context()),
Error: &apiError,
})
}
func write(writer http.ResponseWriter, status int, body responseEnvelope) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(body)
}
+285
View File
@@ -0,0 +1,285 @@
package httpapi
import (
"log/slog"
"net/http"
"time"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/buildinfo"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/health"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/saas"
"data-terminal-cloud/internal/sharing"
"data-terminal-cloud/internal/webevents"
)
type RouterOptions struct {
ServiceName string
Logger *slog.Logger
RateLimiter RateLimiter
Readiness *health.Checker
TokenVerifier *identity.OIDCVerifier
IdentityRepository *identity.Repository
SaaSService *saas.Service
DeviceVerifier DeviceRequestVerifier
DeviceService *device.Repository
DeviceTelemetrySubmitter DeviceTelemetrySubmitter
MediaService *media.Service
WebEventService *webevents.Service
SharingService *sharing.Service
OpsConfigService *opsconfig.Service
BillingService *billing.Service
AlertService *alerts.Service
DiagnosticService *diagnostics.Service
OpsAccessService *opsaccess.Service
ConversationCreator ConversationCreator
ConversationActivator ConversationActivator
DeviceConversationStateReader DeviceConversationStateReader
ConversationHeartbeater ConversationHeartbeater
ConversationStopper ConversationStopper
ConversationTranscriptSubmitter ConversationTranscriptSubmitter
ProviderCallbackSecret []byte
ProviderCallbackSkew time.Duration
ProviderCallbackProcessor ProviderCallbackProcessor
}
func NewRouter(options RouterOptions) http.Handler {
logger := options.Logger
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.Handle("GET /metrics", MetricsHandler())
mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, request, http.StatusOK, map[string]string{
"status": "alive",
"service": options.ServiceName,
})
})
mux.HandleFunc("GET /health/ready", func(writer http.ResponseWriter, request *http.Request) {
result := options.Readiness.Ready(request.Context())
status := http.StatusOK
if result.Status != "ready" {
status = http.StatusServiceUnavailable
}
WriteJSON(writer, request, status, result)
})
mux.HandleFunc("GET /api/v1/version", func(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, request, http.StatusOK, buildinfo.Current())
})
if options.ServiceName == "api" && options.TokenVerifier != nil && options.IdentityRepository != nil {
mux.Handle("GET /api/v1/me", BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.SaaSService != nil {
authenticated := func(handler http.Handler) http.Handler {
return BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, handler))
}
if options.WebEventService != nil {
mux.Handle("GET /api/v1/events", authenticated(StreamWebEventsHandler(options.WebEventService)))
}
if options.SharingService != nil {
mux.Handle("GET /api/v1/sharing-grants", authenticated(ListSharingGrantsHandler(options.SharingService)))
mux.Handle("POST /api/v1/sharing-grants", authenticated(CreateSharingGrantHandler(options.SharingService)))
mux.Handle("DELETE /api/v1/sharing-grants/{id}", authenticated(RevokeSharingGrantHandler(options.SharingService)))
mux.Handle("GET /api/v1/sharing-grants/{id}/resource", authenticated(AccessSharedResourceHandler(options.SharingService)))
mux.Handle("PATCH /api/v1/sharing-grants/{id}/resource", authenticated(EditSharedStoryHandler(options.SharingService)))
}
if options.OpsConfigService != nil {
mux.Handle("GET /api/v1/ops/resource-references", authenticated(ListOpsResourceReferencesHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/resource-references", authenticated(CreateOpsResourceReferenceHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/resource-references/{id}/revoke", authenticated(RevokeOpsResourceReferenceHandler(options.OpsConfigService)))
mux.Handle("GET /api/v1/ops/device-groups", authenticated(ListOpsDeviceGroupsHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/device-groups", authenticated(CreateOpsDeviceGroupHandler(options.OpsConfigService)))
mux.Handle("GET /api/v1/ops/config-rollouts", authenticated(ListOpsConfigRolloutsHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/config-rollouts/{id}/pause", authenticated(PauseOpsConfigRolloutHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/config-rollouts/{id}/resume", authenticated(ResumeOpsConfigRolloutHandler(options.OpsConfigService)))
mux.Handle("GET /api/v1/ops/config-versions", authenticated(ListOpsConfigVersionsHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/config-versions", authenticated(CreateOpsConfigVersionHandler(options.OpsConfigService)))
mux.Handle("GET /api/v1/ops/config-versions/{id}/evaluations", authenticated(ListOpsConfigEvaluationsHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/config-versions/{id}/evaluations", authenticated(CreateOpsConfigEvaluationHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/config-versions/{id}/submit", authenticated(SubmitOpsConfigVersionHandler(options.OpsConfigService)))
mux.Handle("PATCH /api/v1/ops/config-versions/{id}/publish", authenticated(PublishOpsConfigVersionHandler(options.OpsConfigService)))
mux.Handle("POST /api/v1/ops/config-versions/{id}/rollback", authenticated(RollbackOpsConfigVersionHandler(options.OpsConfigService)))
}
if options.BillingService != nil {
mux.Handle("GET /api/v1/ops/price-books", authenticated(ListOpsPriceBooksHandler(options.BillingService)))
mux.Handle("POST /api/v1/ops/price-books", authenticated(CreateOpsPriceBookHandler(options.BillingService)))
mux.Handle("PATCH /api/v1/ops/price-books/{id}/publish", authenticated(PublishOpsPriceBookHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/quota-policies", authenticated(ListOpsQuotaPoliciesHandler(options.BillingService)))
mux.Handle("POST /api/v1/ops/quota-policies", authenticated(CreateOpsQuotaPolicyHandler(options.BillingService)))
mux.Handle("GET /api/v1/ops/cost-summary", authenticated(GetOpsCostSummaryHandler(options.BillingService)))
}
if options.DiagnosticService != nil {
mux.Handle("GET /api/v1/ops/integration-issues", authenticated(ListOpsIntegrationIssuesHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/integration-issues", authenticated(CreateOpsIntegrationIssueHandler(options.DiagnosticService)))
mux.Handle("PATCH /api/v1/ops/integration-issues/{id}", authenticated(UpdateOpsIntegrationIssueHandler(options.DiagnosticService)))
mux.Handle("GET /api/v1/ops/diagnostics/search", authenticated(SearchOpsDiagnosticsHandler(options.DiagnosticService)))
mux.Handle("GET /api/v1/ops/diagnostics/external-dependencies", authenticated(ListOpsExternalDependenciesHandler(options.DiagnosticService)))
mux.Handle("GET /api/v1/ops/diagnostics/jobs", authenticated(ListOpsDiagnosticJobsHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/jobs/{id}/retry", authenticated(RetryOpsDiagnosticJobHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/bundles", authenticated(ExportOpsDiagnosticBundleHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/acceptance-snapshots", authenticated(ExportOpsAcceptanceSnapshotHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/pilot-daily-snapshots", authenticated(ExportOpsPilotDailySnapshotHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/pilot-weekly-snapshots", authenticated(ExportOpsPilotWeeklySnapshotHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/conversations/{id}/stop", authenticated(StopOpsDiagnosticConversationHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/conversations/{id}/reconcile", authenticated(ReconcileOpsDiagnosticConversationHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/devices/{id}/config-refresh", authenticated(RefreshOpsDiagnosticDeviceConfigHandler(options.DiagnosticService)))
mux.Handle("POST /api/v1/ops/diagnostics/devices/{id}/revoke", authenticated(RevokeOpsDiagnosticDeviceHandler(options.DiagnosticService)))
}
if options.OpsAccessService != nil {
mux.Handle("GET /api/v1/ops/sensitive-access-requests", authenticated(ListOpsSensitiveAccessRequestsHandler(options.OpsAccessService)))
mux.Handle("POST /api/v1/ops/sensitive-access-requests", authenticated(CreateOpsSensitiveAccessRequestHandler(options.OpsAccessService)))
mux.Handle("POST /api/v1/ops/sensitive-access-requests/{id}/review", authenticated(ReviewOpsSensitiveAccessRequestHandler(options.OpsAccessService)))
mux.Handle("GET /api/v1/ops/sensitive-conversations/{id}", authenticated(GetOpsSensitiveConversationHandler(options.OpsAccessService)))
mux.Handle("POST /api/v1/ops/sensitive-media/{id}/play", authenticated(CreateOpsSensitiveRecordingAccessHandler(options.OpsAccessService)))
}
mux.Handle("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/households/{id}", authenticated(PatchHouseholdHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/members", authenticated(ListHouseholdMembersHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/households/{id}/members/{member_id}", authenticated(PatchHouseholdMemberHandler(options.SaaSService)))
mux.Handle("DELETE /api/v1/households/{id}/members/{member_id}", authenticated(RemoveHouseholdMemberHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/invitations", authenticated(ListMemberInvitationsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/invitations", authenticated(CreateMemberInvitationHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/invitations/{invitation_id}/revoke", authenticated(RevokeMemberInvitationHandler(options.SaaSService)))
mux.Handle("POST /api/v1/member-invitations/preview", authenticated(PreviewMemberInvitationHandler(options.SaaSService)))
mux.Handle("POST /api/v1/member-invitations/accept", authenticated(AcceptMemberInvitationHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/ownership", authenticated(TransferHouseholdOwnershipHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/subjects", authenticated(ListSubjectsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/subjects", authenticated(CreateSubjectHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}", authenticated(GetSubjectHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/subjects/{id}", authenticated(PatchSubjectHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/conversation-readiness", authenticated(GetConversationReadinessHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/facts", authenticated(ListSubjectFactsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/subjects/{id}/facts", authenticated(CreateSubjectFactHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/subjects/{id}/facts/{fact_id}", authenticated(PatchSubjectFactHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/consents", authenticated(ListConsentsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/subjects/{id}/consents", authenticated(GrantConsentHandler(options.SaaSService)))
mux.Handle("POST /api/v1/subjects/{id}/consents/{consent_id}/withdraw", authenticated(WithdrawConsentHandler(options.SaaSService)))
mux.Handle("POST /api/v1/device-claims", authenticated(ClaimDeviceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices", authenticated(ListManagedDevicesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}", authenticated(GetManagedDeviceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/presence", authenticated(GetDevicePresenceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/twin", authenticated(GetDeviceTwinHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/devices/{id}/twin", authenticated(PatchDeviceTwinHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/commands", authenticated(ListDeviceCommandsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/devices/{id}/commands", authenticated(CreateDeviceCommandHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/firmware-releases", authenticated(PrepareFirmwareReleaseHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/firmware-releases", authenticated(ListFirmwareReleasesHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/firmware-signing-keys", authenticated(RegisterFirmwareSigningKeyHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/firmware-signing-keys", authenticated(ListFirmwareSigningKeysHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/firmware-signing-keys/revoke", authenticated(RevokeFirmwareSigningKeyHandler(options.SaaSService)))
mux.Handle("GET /api/v1/firmware-releases/{id}/impact", authenticated(GetFirmwareReleaseImpactHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-releases/{id}/finalize", authenticated(FinalizeFirmwareReleaseHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-releases/{id}/upload-session", authenticated(RenewFirmwareUploadHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-releases/{id}/pause", authenticated(PauseFirmwareReleaseHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-releases/{id}/resume", authenticated(ResumeFirmwareReleaseHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-releases/{id}/retire", authenticated(RetireFirmwareReleaseHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households/{id}/firmware-rollouts", authenticated(StartFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}/firmware-rollouts", authenticated(ListFirmwareRolloutsHandler(options.SaaSService)))
mux.Handle("GET /api/v1/firmware-rollouts/{id}", authenticated(GetFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-rollouts/{id}/pause", authenticated(PauseFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("POST /api/v1/firmware-rollouts/{id}/rollback", authenticated(RollbackFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/conversations", authenticated(ListSubjectConversationsHandler(options.SaaSService)))
mux.Handle("GET /api/v1/conversations/{id}", authenticated(GetConversationHandler(options.SaaSService)))
mux.Handle("GET /api/v1/conversations/{id}/usage", authenticated(GetConversationUsageHandler(options.SaaSService)))
if options.MediaService != nil {
mux.Handle("GET /api/v1/subjects/{id}/recording-access-grants", authenticated(ListRecordingAccessGrantsHandler(options.MediaService)))
mux.Handle("POST /api/v1/subjects/{id}/recording-access-grants", authenticated(GrantRecordingAccessHandler(options.MediaService)))
mux.Handle("POST /api/v1/recording-access-grants/{id}/revoke", authenticated(RevokeRecordingAccessHandler(options.MediaService)))
mux.Handle("GET /api/v1/conversations/{id}/media", authenticated(GetConversationMediaHandler(options.MediaService)))
mux.Handle("POST /api/v1/media-segments/{id}/access", authenticated(CreateMediaAccessHandler(options.MediaService)))
}
mux.Handle("GET /api/v1/conversations/{id}/turns", authenticated(ListConversationTurnsHandler(options.SaaSService)))
mux.Handle("GET /api/v1/conversations/{id}/summaries", authenticated(ListConversationSummariesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService)))
mux.Handle("POST /api/v1/stories/{id}/confirm", authenticated(ConfirmStoryHandler(options.SaaSService)))
mux.Handle("POST /api/v1/stories/{id}/reject", authenticated(RejectStoryHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/memories", authenticated(ListSubjectMemoriesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/memories/{id}", authenticated(GetMemoryHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/memories/{id}", authenticated(PatchMemoryHandler(options.SaaSService)))
mux.Handle("DELETE /api/v1/memories/{id}", authenticated(DeleteMemoryHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/privacy-requests", authenticated(ListPrivacyRequestsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/subjects/{id}/privacy-requests", authenticated(CreatePrivacyRequestHandler(options.SaaSService)))
mux.Handle("GET /api/v1/privacy-requests/{id}", authenticated(GetPrivacyRequestHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/exports", authenticated(ListExportTasksHandler(options.SaaSService)))
mux.Handle("POST /api/v1/subjects/{id}/exports", authenticated(CreateExportTaskHandler(options.SaaSService)))
mux.Handle("GET /api/v1/exports/{id}", authenticated(GetExportTaskHandler(options.SaaSService)))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.AlertService != nil {
authenticated := func(handler http.Handler) http.Handler {
return BearerAuth(options.TokenVerifier, UserRateLimit(logger, options.RateLimiter, handler))
}
mux.Handle("GET /api/v1/ops/alert-routing-policies", authenticated(ListOpsAlertRoutingPoliciesHandler(options.AlertService)))
mux.Handle("POST /api/v1/ops/alert-routing-policies", authenticated(CreateOpsAlertRoutingPolicyHandler(options.AlertService)))
mux.Handle("GET /api/v1/ops/alert-delivery-tests", authenticated(ListOpsAlertDeliveryTestsHandler(options.AlertService)))
mux.Handle("POST /api/v1/ops/alert-routing-policies/{id}/delivery-tests", authenticated(CreateOpsAlertDeliveryTestHandler(options.AlertService)))
mux.Handle("GET /api/v1/ops/alerts", authenticated(ListOpsAlertsHandler(options.AlertService)))
mux.Handle("GET /api/v1/ops/alerts/{id}", authenticated(GetOpsAlertHandler(options.AlertService)))
mux.Handle("POST /api/v1/ops/alerts/{id}/acknowledge", authenticated(TransitionOpsAlertHandler(options.AlertService, "ACKNOWLEDGE")))
mux.Handle("POST /api/v1/ops/alerts/{id}/mitigate", authenticated(TransitionOpsAlertHandler(options.AlertService, "MITIGATE")))
mux.Handle("POST /api/v1/ops/alerts/{id}/resolve", authenticated(TransitionOpsAlertHandler(options.AlertService, "RESOLVE")))
mux.Handle("GET /api/v1/ops/alert-silences", authenticated(ListOpsAlertSilencesHandler(options.AlertService)))
mux.Handle("POST /api/v1/ops/alert-silences", authenticated(CreateOpsAlertSilenceHandler(options.AlertService)))
mux.Handle("POST /api/v1/ops/alert-silences/{id}/revoke", authenticated(RevokeOpsAlertSilenceHandler(options.AlertService)))
}
if options.ServiceName == "api" && options.SharingService != nil {
mux.Handle("GET /api/v1/public/shares/{id}", AccessPublicSharedResourceHandler(options.SharingService))
}
if options.ServiceName == "api" && options.DeviceVerifier != nil && options.DeviceService != nil {
deviceAuthenticated := func(handler http.Handler) http.Handler {
return DeviceAuth(options.DeviceVerifier, DeviceRateLimit(logger, options.RateLimiter, handler))
}
mux.Handle("GET /device/v2/bootstrap", deviceAuthenticated(DeviceBootstrapHandler(options.DeviceService)))
mux.Handle("GET /device/v2/config", deviceAuthenticated(DeviceConfigHandler(options.DeviceService)))
mux.Handle("GET /device/v2/commands", deviceAuthenticated(PullDeviceCommandsHandler(options.DeviceService)))
mux.Handle("POST /device/v2/commands/{id}/ack", deviceAuthenticated(AcknowledgeDeviceCommandHandler(options.DeviceService)))
if options.DeviceTelemetrySubmitter != nil {
mux.Handle("POST /device/v2/telemetry:batch", deviceAuthenticated(SubmitDeviceTelemetryHandler(options.DeviceTelemetrySubmitter)))
}
mux.Handle("GET /device/v2/firmware-releases/{id}/download", deviceAuthenticated(DeviceFirmwareDownloadHandler(options.SaaSService)))
if options.MediaService != nil {
mux.Handle("POST /device/v2/conversations/{id}/upload-batches", deviceAuthenticated(PrepareMediaUploadBatchHandler(options.MediaService)))
mux.Handle("POST /device/v2/upload-batches/{id}/complete", deviceAuthenticated(CompleteMediaUploadBatchHandler(options.MediaService)))
mux.Handle("GET /device/v2/upload-batches/{id}", deviceAuthenticated(GetDeviceMediaUploadBatchHandler(options.MediaService)))
mux.Handle("POST /device/v2/media-segments/{id}/local-delete-ack", deviceAuthenticated(AcknowledgeLocalMediaDeleteHandler(options.MediaService)))
}
if options.ConversationCreator != nil {
mux.Handle("POST /device/v2/conversations", deviceAuthenticated(CreateConversationHandler(options.ConversationCreator)))
}
if options.ConversationActivator != nil {
mux.Handle("POST /device/v2/conversations/{id}/activate", deviceAuthenticated(ActivateConversationHandler(options.ConversationActivator)))
}
if options.DeviceConversationStateReader != nil {
mux.Handle("GET /device/v2/conversations/{id}", deviceAuthenticated(GetDeviceConversationStateHandler(options.DeviceConversationStateReader)))
}
if options.ConversationHeartbeater != nil {
mux.Handle("POST /device/v2/conversations/{id}/heartbeat", deviceAuthenticated(HeartbeatConversationHandler(options.ConversationHeartbeater)))
}
if options.ConversationStopper != nil {
mux.Handle("POST /device/v2/conversations/{id}/stop", deviceAuthenticated(StopConversationHandler(options.ConversationStopper)))
}
if options.ConversationTranscriptSubmitter != nil {
mux.Handle("POST /device/v2/conversations/{id}/turns:batch", deviceAuthenticated(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
}
}
if options.ServiceName == "callbacks" && len(options.ProviderCallbackSecret) > 0 && options.ProviderCallbackProcessor != nil {
mux.Handle("POST /callbacks/v1/vertc/voice-chat", VoiceProviderCallbackHandler(
options.ProviderCallbackSecret, options.ProviderCallbackSkew, options.ProviderCallbackProcessor,
))
}
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
WriteError(writer, request, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的资源不存在")
})
return mux
}
+142
View File
@@ -0,0 +1,142 @@
package httpapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/health"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/saas"
"data-terminal-cloud/internal/sharing"
"data-terminal-cloud/internal/webevents"
)
func TestLiveEndpointReturnsRequestID(t *testing.T) {
checker := health.NewChecker(time.Second, map[string]health.CheckFunc{})
router := NewRouter(RouterOptions{ServiceName: "api", Readiness: checker})
handler := Middleware(slog.Default(), "test", 1024, nil, router)
request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
request.Header.Set("X-Request-Id", "test-request")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if recorder.Header().Get("X-Request-Id") != "test-request" {
t.Fatalf("request id header was not preserved")
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["request_id"] != "test-request" {
t.Fatalf("unexpected request_id: %#v", body["request_id"])
}
}
func TestMetricsExposeHTTPSeries(t *testing.T) {
handler := Middleware(slog.Default(), "metrics-test", 1024, nil, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "/example", nil)
request.Pattern = "GET /example"
handler.ServeHTTP(httptest.NewRecorder(), request)
recorder := httptest.NewRecorder()
MetricsHandler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `voice_platform_http_requests_total{method="GET",route="GET /example",service="metrics-test",status="204"} 1`) {
t.Fatalf("HTTP metric missing from scrape")
}
}
func TestReadyEndpointReturnsServiceUnavailable(t *testing.T) {
checker := health.NewChecker(time.Second, map[string]health.CheckFunc{
"database": func(context.Context) error { return context.DeadlineExceeded },
})
router := NewRouter(RouterOptions{ServiceName: "api", Readiness: checker})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
}
func TestNewRouterRegistersCompleteAPIAndCallbackSurface(t *testing.T) {
checker := health.NewChecker(time.Second, map[string]health.CheckFunc{})
apiRouter := NewRouter(RouterOptions{
ServiceName: "api",
Readiness: checker,
TokenVerifier: new(identity.OIDCVerifier),
IdentityRepository: new(identity.Repository),
SaaSService: new(saas.Service),
DeviceVerifier: fakeDeviceVerifier{err: deviceauth.ErrCredentialsMissing},
DeviceService: new(device.Repository),
DeviceTelemetrySubmitter: &fakeTelemetrySubmitter{},
MediaService: new(media.Service),
WebEventService: new(webevents.Service),
SharingService: new(sharing.Service),
OpsConfigService: new(opsconfig.Service),
BillingService: new(billing.Service),
AlertService: new(alerts.Service),
DiagnosticService: new(diagnostics.Service),
OpsAccessService: new(opsaccess.Service),
ConversationCreator: &fakeConversationCreator{},
ConversationActivator: &fakeConversationActivator{},
DeviceConversationStateReader: &fakeDeviceConversationStateReader{},
ConversationHeartbeater: &fakeConversationHeartbeater{},
ConversationStopper: &fakeConversationStopper{},
ConversationTranscriptSubmitter: &fakeConversationTranscriptSubmitter{},
})
for _, target := range []string{
"/api/v1/me",
"/api/v1/households",
"/api/v1/ops/alerts",
"/api/v1/ops/sensitive-access-requests",
"/api/v1/events",
} {
recorder := httptest.NewRecorder()
apiRouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("protected route %s status=%d body=%s", target, recorder.Code, recorder.Body.String())
}
}
recorder := httptest.NewRecorder()
apiRouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/device/v2/bootstrap", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("device route status=%d body=%s", recorder.Code, recorder.Body.String())
}
_, pattern := apiRouter.(*http.ServeMux).Handler(httptest.NewRequest(http.MethodGet, "/api/v1/public/shares/"+strings.Repeat("a", 36), nil))
if pattern != "GET /api/v1/public/shares/{id}" {
t.Fatalf("public sharing route pattern=%q", pattern)
}
callbackRouter := NewRouter(RouterOptions{
ServiceName: "callbacks",
Readiness: checker,
ProviderCallbackSecret: []byte("01234567890123456789012345678901"),
ProviderCallbackSkew: time.Minute,
ProviderCallbackProcessor: &fakeProviderCallbackProcessor{},
})
recorder = httptest.NewRecorder()
callbackRouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/callbacks/v1/vertc/voice-chat", strings.NewReader(`{}`)))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("callback route status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/saas"
)
func TestSaaSInputHelpers(t *testing.T) {
if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", map[string]any{}, map[string]any{}); len(fields) != 0 {
t.Fatalf("valid subject fields=%#v", fields)
}
if fields := validateSubject("", "zh_CN", "Unknown/Zone", nil, nil); len(fields) != 5 {
t.Fatalf("invalid subject fields=%#v", fields)
}
validProfile := map[string]any{"background": "铁路工作经历", "interests": []any{"老照片", "家乡变化"}}
validPreferences := map[string]any{"conversation_goal": "聊聊第一次工作", "pace": "slow", "preferred_topics": []any{"工作经历"}}
if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", validProfile, validPreferences); len(fields) != 0 {
t.Fatalf("valid conversation context fields=%#v", fields)
}
invalidProfile := map[string]any{"background": strings.Repeat("长", 501), "interests": []any{"", 1}}
invalidPreferences := map[string]any{"conversation_goal": strings.Repeat("长", 201), "pace": "fast", "preferred_topics": strings.Repeat("x", 9)}
if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", invalidProfile, invalidPreferences); len(fields) != 5 {
t.Fatalf("invalid conversation context fields=%#v", fields)
}
if !validLanguage("zh-CN") || validLanguage("zh_CN") || !validTimezone("UTC") || validTimezone("Unknown/Zone") || !validUUID("10000000-0000-0000-0000-000000000001") || validUUID("bad") {
t.Fatal("language, timezone or UUID validation contract changed")
}
fact := saas.CreateFactInput{FactType: " hometown ", Content: " 合成城市 ", SourceType: " model_candidate ", SourceRef: " synthetic://turn/1 ", ConfirmationStatus: " pending ", Sensitivity: " normal "}
normalizeCreateFact(&fact)
if fields := validateFact(fact.FactType, fact.Content, fact.SourceType, fact.SourceRef, fact.ConfirmationStatus, fact.Sensitivity, nil, nil); len(fields) != 0 {
t.Fatalf("valid fact fields=%#v", fields)
}
now := time.Now().UTC()
earlier := now.Add(-time.Hour)
if fields := validateFact("", "", "unknown", strings.Repeat("x", 301), "confirmed", "unknown", &now, &earlier); len(fields) != 6 {
t.Fatalf("invalid fact fields=%#v", fields)
}
config := map[string]any{"audio": []any{map[string]any{"codec": "opus"}, map[string]any{"api_token": "secret"}}}
if path, found := forbiddenDeviceConfigField(config, "desired_config"); !found || path != "desired_config.audio[1].api_token" {
t.Fatalf("forbidden config path=%q found=%v", path, found)
}
if _, found := forbiddenDeviceConfigField(map[string]any{"audio": map[string]any{"codec": "opus"}}, "desired_config"); found {
t.Fatal("safe device configuration was rejected")
}
for _, config := range []map[string]any{
{"volume": map[string]any{"speaker": float64(0)}},
{"volume": map[string]any{"speaker": float64(65)}},
{"volume": map[string]any{"speaker": float64(100)}},
{"interaction": map[string]any{"wake_word": "你好"}},
} {
if fields := validateDesiredDeviceConfig(config); len(fields) != 0 {
t.Fatalf("valid desired device config=%#v fields=%#v", config, fields)
}
}
for _, config := range []map[string]any{
{"volume": "loud"},
{"volume": map[string]any{"speaker": float64(-1)}},
{"volume": map[string]any{"speaker": 50.5}},
{"volume": map[string]any{"speaker": float64(101)}},
{"volume": map[string]any{"speaker": "65"}},
} {
if fields := validateDesiredDeviceConfig(config); len(fields) != 1 {
t.Fatalf("invalid desired device config=%#v fields=%#v", config, fields)
}
}
}
func TestDecodeAndResourcePrincipal(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"ok"}`))
recorder := httptest.NewRecorder()
var body struct {
Name string `json:"name"`
}
if !decodeWebJSON(recorder, request, &body, "请求错误") || body.Name != "ok" {
t.Fatalf("decoded body=%#v status=%d", body, recorder.Code)
}
request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"ok","extra":1}`))
recorder = httptest.NewRecorder()
if decodeWebJSON(recorder, request, &body, "请求错误") || recorder.Code != http.StatusBadRequest {
t.Fatalf("unknown field status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodGet, "/resources/bad", nil)
request.SetPathValue("id", "bad")
request = request.WithContext(identity.WithPrincipal(request.Context(), identity.Principal{Issuer: "issuer", Subject: "subject"}))
recorder = httptest.NewRecorder()
if _, ok := resourcePrincipal(recorder, request, "id"); ok || recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid resource principal status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodGet, "/resources/1", nil)
recorder = httptest.NewRecorder()
if _, ok := resourcePrincipal(recorder, request, "id"); ok || recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous resource principal status=%d", recorder.Code)
}
}
func TestSaaSErrorAndETagMapping(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{saas.ErrAccessDenied, http.StatusForbidden, "RESOURCE_ACCESS_DENIED"},
{saas.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{saas.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"},
{saas.ErrEffectiveConsentExist, http.StatusConflict, "EFFECTIVE_CONSENT_EXISTS"},
{saas.ErrPolicyVersionMismatch, http.StatusConflict, "POLICY_VERSION_MISMATCH"},
{saas.ErrDeviceClaimInvalid, http.StatusConflict, "DEVICE_CLAIM_INVALID"},
{saas.ErrDeviceClaimUsed, http.StatusConflict, "DEVICE_CLAIM_ALREADY_USED"},
{saas.ErrDeviceAlreadyBound, http.StatusConflict, "DEVICE_ALREADY_BOUND"},
{saas.ErrIdempotencyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT"},
{saas.ErrSubjectDeleteConflict, http.StatusConflict, "SUBJECT_ACTIVE_CONVERSATION"},
{saas.ErrDeleteConfirmation, http.StatusConflict, "DELETE_CONFIRMATION_MISMATCH"},
{saas.ErrExportUnavailable, http.StatusServiceUnavailable, "EXPORT_DOWNLOAD_UNAVAILABLE"},
{saas.ErrFirmwareStateConflict, http.StatusConflict, "FIRMWARE_STATE_CONFLICT"},
{saas.ErrFirmwareSigningKeyConflict, http.StatusConflict, "FIRMWARE_SIGNING_KEY_CONFLICT"},
{saas.ErrFirmwareSigningKeyInUse, http.StatusConflict, "FIRMWARE_SIGNING_KEY_IN_USE"},
{saas.ErrFirmwareSigningKeyInactive, http.StatusConflict, "FIRMWARE_SIGNING_KEY_INACTIVE"},
{saas.ErrFirmwareReleaseInUse, http.StatusConflict, "FIRMWARE_RELEASE_IN_USE"},
{saas.ErrInvitationInvalid, http.StatusConflict, "MEMBER_INVITATION_INVALID"},
{saas.ErrMemberExists, http.StatusConflict, "HOUSEHOLD_MEMBER_EXISTS"},
{saas.ErrOwnerProtected, http.StatusConflict, "HOUSEHOLD_OWNER_PROTECTED"},
{saas.ErrFactSourceRequired, http.StatusConflict, "FACT_SOURCE_REQUIRED"},
{saas.ErrStorySourceRequired, http.StatusConflict, "STORY_SOURCE_REQUIRED"},
{saas.ErrPersonalizedMemoryConsentNeeded, http.StatusConflict, "PERSONALIZED_MEMORY_CONSENT_REQUIRED"},
{saas.ErrReviewStateConflict, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT"},
{pagination.ErrInvalidCursor, http.StatusBadRequest, "VALIDATION_FAILED"},
{errors.New("unexpected"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, item := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil)
writeSaaSError(recorder, request, item.err)
var response responseEnvelope
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != item.status || response.Error == nil || response.Error.Code != item.code {
t.Fatalf("error=%v status=%d body=%s", item.err, recorder.Code, recorder.Body.String())
}
}
for _, item := range []struct {
err error
status int
}{
{concurrency.ErrPreconditionRequired, http.StatusPreconditionRequired},
{concurrency.ErrVersionConflict, http.StatusPreconditionFailed},
{errors.New("bad etag"), http.StatusBadRequest},
} {
recorder := httptest.NewRecorder()
writeETagError(recorder, httptest.NewRequest(http.MethodPatch, "/", nil), item.err)
if recorder.Code != item.status {
t.Fatalf("etag error=%v status=%d", item.err, recorder.Code)
}
}
}
@@ -0,0 +1,80 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/saas"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSaaSReadHandlersWithAuthenticatedSeedData(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
cursorCodec, err := pagination.NewCodec([]byte("http-saas-read-cursor-signing-key-32-bytes"))
if err != nil {
t.Fatal(err)
}
service := saas.NewService(pool, audit.NewRepository(), nil, cursorCodec)
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
tests := []struct {
name string
handler http.Handler
pathValue string
header string
}{
{"households", ListHouseholdsHandler(service), "", "10000000-0000-0000-0000-000000000010"},
{"household", GetHouseholdHandler(service), "10000000-0000-0000-0000-000000000211", ""},
{"household members", ListHouseholdMembersHandler(service), "10000000-0000-0000-0000-000000000211", ""},
{"member invitations", ListMemberInvitationsHandler(service), "10000000-0000-0000-0000-000000000211", ""},
{"subjects", ListSubjectsHandler(service), "10000000-0000-0000-0000-000000000211", ""},
{"subject", GetSubjectHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"conversation readiness", GetConversationReadinessHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"consents", ListConsentsHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"facts", ListSubjectFactsHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"conversations", ListSubjectConversationsHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"stories", ListSubjectStoriesHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"memories", ListSubjectMemoriesHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"privacy requests", ListPrivacyRequestsHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"exports", ListExportTasksHandler(service), "10000000-0000-0000-0000-000000000411", ""},
{"devices", ListManagedDevicesHandler(service), "", ""},
{"device", GetManagedDeviceHandler(service), "10000000-0000-0000-0000-000000000511", ""},
{"device commands", ListDeviceCommandsHandler(service), "10000000-0000-0000-0000-000000000511", ""},
{"presence", GetDevicePresenceHandler(service), "10000000-0000-0000-0000-000000000511", ""},
{"twin", GetDeviceTwinHandler(service), "10000000-0000-0000-0000-000000000511", ""},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
request = request.WithContext(identity.WithPrincipal(request.Context(), principal))
if item.pathValue != "" {
request.SetPathValue("id", item.pathValue)
}
if item.header != "" {
request.Header.Set("X-Tenant-Id", item.header)
}
recorder := httptest.NewRecorder()
item.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("content type=%q", contentType)
}
})
}
}
@@ -0,0 +1,167 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSaaSHouseholdAndSubjectWriteHandlers(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service := saas.NewService(pool, audit.NewRepository(), nil, nil)
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
var householdID, subjectID, factID, consentID string
defer func() {
if subjectID != "" {
if consentID != "" {
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, consentID)
}
if factID != "" {
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, factID)
}
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_record WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, subjectID)
_, _ = pool.Exec(context.Background(), `DELETE FROM subject_profile WHERE public_id=$1::uuid`, subjectID)
}
if householdID != "" {
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, householdID)
_, _ = pool.Exec(context.Background(), `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, householdID)
_, _ = pool.Exec(context.Background(), `DELETE FROM household WHERE public_id=$1::uuid`, householdID)
}
}()
request := authenticatedRequest(http.MethodPost, "/api/v1/households", `{"name":"HTTP写接口集成家庭"}`, principal)
request.Header.Set("X-Tenant-Id", "10000000-0000-0000-0000-000000000010")
recorder := httptest.NewRecorder()
CreateHouseholdHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create household status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var householdResponse struct {
Data saas.Household `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &householdResponse); err != nil {
t.Fatal(err)
}
householdID = householdResponse.Data.ID
request = authenticatedRequest(http.MethodPatch, "/api/v1/households/"+householdID, `{"name":"HTTP写接口集成家庭-修改"}`, principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
PatchHouseholdHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("missing household ETag status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPatch, "/api/v1/households/"+householdID, `{"name":"HTTP写接口集成家庭-修改"}`, principal)
request.SetPathValue("id", householdID)
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
PatchHouseholdHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("patch household status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/subjects", `{"preferred_name":"HTTP集成长者","language":"zh-CN","timezone":"Asia/Shanghai","profile_data":{"synthetic":true},"conversation_preferences":{"pace":"slow"}}`, principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
CreateSubjectHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create subject status=%d body=%s", recorder.Code, recorder.Body.String())
}
var subjectResponse struct {
Data saas.Subject `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &subjectResponse); err != nil {
t.Fatal(err)
}
subjectID = subjectResponse.Data.ID
request = authenticatedRequest(http.MethodPatch, "/api/v1/subjects/"+subjectID, `{"preferred_name":"HTTP集成长者-修改"}`, principal)
request.SetPathValue("id", subjectID)
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
PatchSubjectHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("patch subject status=%d body=%s", recorder.Code, recorder.Body.String())
}
var patchedSubject struct {
Data saas.Subject `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &patchedSubject); err != nil || patchedSubject.Data.PreferredName != "HTTP集成长者-修改" {
t.Fatalf("patched subject=%+v err=%v", patchedSubject, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/facts", `{"fact_type":"hometown","content":"合成城市","source_type":"family_entry","source_ref":"http-integration://source","confirmation_status":"pending","sensitivity":"normal"}`, principal)
request.SetPathValue("id", subjectID)
recorder = httptest.NewRecorder()
CreateSubjectFactHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) {
t.Fatalf("create fact status=%d body=%s", recorder.Code, recorder.Body.String())
}
var factResponse struct {
Data saas.SubjectFact `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &factResponse); err != nil {
t.Fatal(err)
}
factID = factResponse.Data.ID
request = authenticatedRequest(http.MethodPatch, "/api/v1/subjects/"+subjectID+"/facts/"+factID, `{"confirmation_status":"confirmed"}`, principal)
request.SetPathValue("id", subjectID)
request.SetPathValue("fact_id", factID)
request.Header.Set("If-Match", concurrency.FormatETag(1))
recorder = httptest.NewRecorder()
PatchSubjectFactHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) {
t.Fatalf("patch fact status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/consents", `{"type":"story_extraction","scope":{"future":true},"policy_version":"local-v1","notice_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","evidence_ref":"http-integration://consent"}`, principal)
request.SetPathValue("id", subjectID)
recorder = httptest.NewRecorder()
GrantConsentHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("grant consent status=%d body=%s", recorder.Code, recorder.Body.String())
}
var consentResponse struct {
Data saas.Consent `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &consentResponse); err != nil {
t.Fatal(err)
}
consentID = consentResponse.Data.ID
request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/consents/"+consentID+":withdraw", ``, principal)
request.SetPathValue("id", subjectID)
request.SetPathValue("consent_id", consentID)
recorder = httptest.NewRecorder()
WithdrawConsentHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("withdraw consent status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func authenticatedRequest(method, target, body string, principal identity.Principal) *http.Request {
request := httptest.NewRequest(method, target, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
return request.WithContext(identity.WithPrincipal(request.Context(), principal))
}
+175
View File
@@ -0,0 +1,175 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/sharing"
)
func CreateSharingGrantHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(key) < 16 || len(key) > 128 {
writeValidation(w, r, "缺少有效的重复请求识别码", FieldError{Field: "Idempotency-Key", Message: "长度必须为16至128字符"})
return
}
var body struct {
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
GranteeUserID string `json:"grantee_user_id"`
Permission string `json:"permission"`
Purpose string `json:"purpose"`
ExpiresAt time.Time `json:"expires_at"`
LoginRequired *bool `json:"login_required"`
}
if !decodeWebJSON(w, r, &body, "分享授权参数不正确") {
return
}
loginRequired := true
if body.LoginRequired != nil {
loginRequired = *body.LoginRequired
}
input := sharing.CreateInput{ResourceType: body.ResourceType, ResourceID: body.ResourceID, GranteeUserID: body.GranteeUserID, Permission: body.Permission, Purpose: body.Purpose, ExpiresAt: body.ExpiresAt, LoginRequired: loginRequired}
if !validUUID(input.ResourceID) || !validUUID(input.GranteeUserID) || input.ExpiresAt.IsZero() {
writeValidation(w, r, "分享授权参数不正确", FieldError{Field: "grant", Message: "资源、被分享用户和到期时间均为必填"})
return
}
item, created, err := service.Create(r.Context(), principal, key, RequestID(r.Context()), input)
if err != nil {
writeSharingError(w, r, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
w.Header().Set("Idempotency-Replayed", "true")
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, status, item)
})
}
func ListSharingGrantsHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
subjectID := strings.TrimSpace(r.URL.Query().Get("subject_id"))
if !validUUID(subjectID) {
writeValidation(w, r, "分享授权查询参数不正确", FieldError{Field: "subject_id", Message: "必须是有效UUID"})
return
}
items, err := service.List(r.Context(), principal, subjectID)
if err != nil {
writeSharingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
})
}
func RevokeSharingGrantHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.Revoke(r.Context(), principal, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSharingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func AccessSharedResourceHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
item, err := service.Access(r.Context(), principal, r.PathValue("id"), RequestID(r.Context()))
if err != nil {
writeSharingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func AccessPublicSharedResourceHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
token := strings.TrimSpace(r.Header.Get("X-Share-Token"))
if !validUUID(id) || len(token) < 32 || len(token) > 256 {
writeValidation(w, r, "分享链接参数不正确", FieldError{Field: "share", Message: "分享编号或访问口令无效"})
return
}
item, err := service.AccessByToken(r.Context(), id, token, RequestID(r.Context()))
if err != nil {
writeSharingError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func EditSharedStoryHandler(service *sharing.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
expected, err := concurrency.ParseIfMatch(r)
if err != nil {
writeETagError(w, r, err)
return
}
var input struct {
Content string `json:"content"`
Reason string `json:"reason"`
}
if !decodeWebJSON(w, r, &input, "共享故事修改参数不正确") {
return
}
item, err := service.EditStory(r.Context(), principal, r.PathValue("id"), input.Content, input.Reason, RequestID(r.Context()), expected)
if err != nil {
writeSharingError(w, r, err)
return
}
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, item)
})
}
func writeSharingError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, sharing.ErrInvalid):
writeValidation(w, r, "分享授权参数不正确", FieldError{Field: "grant", Message: "资源类型、权限、用途或到期时间不符合要求"})
case errors.Is(err, sharing.ErrAccessDenied):
WriteError(w, r, http.StatusForbidden, "SHARING_ACCESS_DENIED", "当前账号没有创建、管理或访问该分享的权限")
case errors.Is(err, sharing.ErrExpired):
WriteError(w, r, http.StatusGone, "SHARING_GRANT_INACTIVE", "分享已经撤销或过期")
case errors.Is(err, sharing.ErrNotFound):
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "分享授权或目标资源不存在")
case errors.Is(err, sharing.ErrVersionConflict):
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "共享资源已经被其他用户修改,请刷新后重试")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "分享授权处理失败")
}
}
+88
View File
@@ -0,0 +1,88 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/sharing"
"github.com/google/uuid"
)
func sharingRequest(method, target, body string, authenticated bool) *http.Request {
request := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if authenticated {
request = request.WithContext(identity.WithPrincipal(request.Context(), identity.Principal{Issuer: "issuer", Subject: "subject"}))
}
return request
}
func TestSharingHandlersRejectInvalidAuthenticationAndInputsBeforeService(t *testing.T) {
validID := uuid.NewString()
tests := []struct {
name string
handler http.Handler
request *http.Request
status int
code string
}{
{"create unauthenticated", CreateSharingGrantHandler(nil), sharingRequest(http.MethodPost, "/api/v1/sharing-grants", `{}`, false), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"create short idempotency", CreateSharingGrantHandler(nil), sharingRequest(http.MethodPost, "/api/v1/sharing-grants", `{}`, true), http.StatusBadRequest, "VALIDATION_FAILED"},
{"create malformed body", CreateSharingGrantHandler(nil), sharingRequest(http.MethodPost, "/api/v1/sharing-grants", `{`, true), http.StatusBadRequest, "VALIDATION_FAILED"},
{"create invalid grant", CreateSharingGrantHandler(nil), sharingRequest(http.MethodPost, "/api/v1/sharing-grants", `{"resource_type":"story","resource_id":"bad","grantee_user_id":"bad","expires_at":"2026-08-24T00:00:00Z"}`, true), http.StatusBadRequest, "VALIDATION_FAILED"},
{"list unauthenticated", ListSharingGrantsHandler(nil), sharingRequest(http.MethodGet, "/api/v1/sharing-grants?subject_id="+validID, "", false), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"list invalid subject", ListSharingGrantsHandler(nil), sharingRequest(http.MethodGet, "/api/v1/sharing-grants?subject_id=bad", "", true), http.StatusBadRequest, "VALIDATION_FAILED"},
{"revoke unauthenticated", RevokeSharingGrantHandler(nil), sharingRequest(http.MethodPost, "/api/v1/sharing-grants/"+validID+"/revoke", `{}`, false), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"access unauthenticated", AccessSharedResourceHandler(nil), sharingRequest(http.MethodGet, "/api/v1/sharing-grants/"+validID+"/resource", "", false), http.StatusUnauthorized, "AUTHENTICATION_REQUIRED"},
{"public invalid token", AccessPublicSharedResourceHandler(nil), sharingRequest(http.MethodGet, "/public/v1/sharing-grants/"+validID, "", false), http.StatusBadRequest, "VALIDATION_FAILED"},
{"edit missing if-match", EditSharedStoryHandler(nil), sharingRequest(http.MethodPatch, "/api/v1/sharing-grants/"+validID+"/story", `{"content":"updated","reason":"review"}`, true), http.StatusPreconditionRequired, "PRECONDITION_REQUIRED"},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
if strings.Contains(item.name, "create ") && !strings.Contains(item.name, "unauthenticated") && !strings.Contains(item.name, "short") {
item.request.Header.Set("Idempotency-Key", "sharing-handler-test-key-1234")
}
if strings.Contains(item.name, "revoke") || strings.Contains(item.name, "access unauthenticated") || strings.Contains(item.name, "edit") {
item.request.SetPathValue("id", validID)
}
if strings.Contains(item.name, "public") {
item.request.SetPathValue("id", validID)
}
recorder := httptest.NewRecorder()
item.handler.ServeHTTP(recorder, item.request)
if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestWriteSharingErrorStableMappings(t *testing.T) {
tests := []struct {
err error
status int
code string
}{
{sharing.ErrInvalid, http.StatusBadRequest, "VALIDATION_FAILED"},
{sharing.ErrAccessDenied, http.StatusForbidden, "SHARING_ACCESS_DENIED"},
{sharing.ErrExpired, http.StatusGone, "SHARING_GRANT_INACTIVE"},
{sharing.ErrNotFound, http.StatusNotFound, "RESOURCE_NOT_FOUND"},
{sharing.ErrVersionConflict, http.StatusPreconditionFailed, "VERSION_CONFLICT"},
{errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"},
}
for _, item := range tests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/sharing-grants", nil)
writeSharingError(recorder, request, item.err)
if recorder.Code != item.status || !strings.Contains(recorder.Body.String(), item.code) {
t.Fatalf("error=%v status=%d body=%s", item.err, recorder.Code, recorder.Body.String())
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"unicode/utf8"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
)
type DeviceTelemetrySubmitter interface {
Submit(context.Context, deviceauth.Identity, string, []byte, device.TelemetryBatchInput) (device.TelemetryBatchResponse, bool, error)
}
func SubmitDeviceTelemetryHandler(service DeviceTelemetrySubmitter) 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 device.TelemetryBatchInput
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 := validateTelemetryBatch(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, idempotencyKey, rawBody, input)
if err != nil {
writeTelemetryError(writer, request, err)
return
}
if replayed {
writer.Header().Set("Idempotency-Replayed", "true")
}
WriteJSON(writer, request, http.StatusOK, response)
})
}
func validateTelemetryBatch(input device.TelemetryBatchInput) []FieldError {
fieldErrors := make([]FieldError, 0)
if len(input.Events) < 1 || len(input.Events) > 500 {
return append(fieldErrors, FieldError{Field: "events", Message: "必须包含1至500条遥测事件"})
}
eventKeys := make(map[string]struct{}, len(input.Events))
sequences := make(map[int64]struct{}, len(input.Events))
for index, event := range input.Events {
prefix := fmt.Sprintf("events[%d]", index)
eventKey := strings.TrimSpace(event.EventKey)
if eventKey == "" || utf8.RuneCountInString(eventKey) > 255 || !isSafeErrorCode(eventKey) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".event_key", Message: "必须是最长255字符的安全业务键"})
} else if _, exists := eventKeys[eventKey]; exists {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".event_key", Message: "请求内不能重复"})
} else {
eventKeys[eventKey] = struct{}{}
}
if event.SequenceNo < 1 {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".sequence_no", Message: "必须大于0"})
} else if _, exists := sequences[event.SequenceNo]; exists {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".sequence_no", Message: "请求内不能重复"})
} else {
sequences[event.SequenceNo] = struct{}{}
}
if event.OccurredAt.IsZero() {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".occurred_at", Message: "必须是RFC3339时间"})
}
if event.ConversationID != "" && !validUUID(event.ConversationID) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".conversation_id", Message: "必须是UUID"})
}
switch event.EventType {
case "status":
if event.Status == nil || event.Error != nil || event.CommandResult != nil {
fieldErrors = append(fieldErrors, FieldError{Field: prefix, Message: "status事件必须且只能包含status对象"})
continue
}
if strings.TrimSpace(event.Status.FirmwareVersion) == "" || utf8.RuneCountInString(event.Status.FirmwareVersion) > 80 {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.firmware_version", Message: "不能为空且最长80字符"})
}
if event.Status.ReportedConfigRevision < 1 {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.reported_config_revision", Message: "必须大于0"})
}
if strings.TrimSpace(event.Status.Network.Type) == "" || utf8.RuneCountInString(event.Status.Network.Type) > 40 {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.network.type", Message: "不能为空且最长40字符"})
}
if event.Status.Network.RSSI != nil && (*event.Status.Network.RSSI < -127 || *event.Status.Network.RSSI > 0) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.network.rssi", Message: "必须为-127至0"})
}
if event.Status.RecordingQueue.PendingSegments < 0 || event.Status.RecordingQueue.FailedSegments < 0 {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.recording_queue", Message: "分片数量不能为负数"})
}
if utf8.RuneCountInString(event.Status.RecordingQueue.LastFailureCode) > 80 || !isSafeErrorCode(event.Status.RecordingQueue.LastFailureCode) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.recording_queue.last_failure_code", Message: "只能是最长80字符的错误码"})
}
if event.Status.RecordingQueue.LastFailureAt != nil && event.Status.RecordingQueue.LastFailureAt.IsZero() {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.recording_queue.last_failure_at", Message: "必须是RFC3339时间"})
}
if event.Status.ConversationStatus != "" && !oneOf(event.Status.ConversationStatus, "PREPARING", "ROOM_ISSUED", "STARTING_AGENT", "ACTIVE", "STOPPING", "COMPLETED", "FAILED", "EXPIRED") {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".status.conversation_status", Message: "不是有效的通话状态"})
}
case "error":
if event.Error == nil || event.Status != nil || event.CommandResult != nil {
fieldErrors = append(fieldErrors, FieldError{Field: prefix, Message: "error事件必须且只能包含error对象"})
continue
}
if event.Error.Code == "" || utf8.RuneCountInString(event.Error.Code) > 80 || !isSafeErrorCode(event.Error.Code) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".error.code", Message: "必须是最长80字符的错误码"})
}
if !oneOf(event.Error.Severity, "warning", "error", "critical") {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".error.severity", Message: "必须为warning、error或critical"})
}
case "command_result":
if event.CommandResult == nil || event.Status != nil || event.Error != nil {
fieldErrors = append(fieldErrors, FieldError{Field: prefix, Message: "command_result事件必须且只能包含command_result对象"})
continue
}
if !validUUID(event.CommandResult.CommandID) {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".command_result.command_id", Message: "必须是UUID"})
}
if !oneOf(event.CommandResult.Status, "ACKNOWLEDGED", "SUCCEEDED", "FAILED") {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".command_result.status", Message: "必须为ACKNOWLEDGED、SUCCEEDED或FAILED"})
}
if event.CommandResult.ResultSummary == nil {
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".command_result.result_summary", Message: "必须是JSON对象"})
} else if path, found := forbiddenDeviceConfigField(event.CommandResult.ResultSummary, prefix+".command_result.result_summary"); found {
fieldErrors = append(fieldErrors, FieldError{Field: path, Message: "命令结果不得包含密钥、令牌或凭据字段"})
}
default:
fieldErrors = append(fieldErrors, FieldError{Field: prefix + ".event_type", Message: "必须为status、error或command_result"})
}
}
return fieldErrors
}
func writeTelemetryError(writer http.ResponseWriter, request *http.Request, err error) {
var conflict *device.TelemetryConflictError
switch {
case errors.As(err, &conflict):
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "TELEMETRY_EVENT_CONFLICT", Message: "遥测业务键与已保存事件冲突", Details: map[string]any{"conflicts": conflict.Conflicts}})
case errors.Is(err, device.ErrTelemetryConfigAhead):
WriteError(writer, request, http.StatusConflict, "CONFIG_VERSION_CONFLICT", "设备报告的配置版本高于云端期望版本")
case errors.Is(err, device.ErrTelemetryConversationMissing):
WriteError(writer, request, http.StatusNotFound, "TELEMETRY_CONVERSATION_NOT_FOUND", "关联通话不存在或不属于当前设备")
case errors.Is(err, device.ErrTelemetryCommandConflict):
WriteError(writer, request, http.StatusConflict, "DEVICE_COMMAND_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: "DEVICE_TELEMETRY_UNAVAILABLE", Message: "暂时无法处理设备遥测", Retryable: true})
}
}
@@ -0,0 +1,65 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
)
type fakeTelemetrySubmitter struct {
response device.TelemetryBatchResponse
replayed bool
err error
calls int
}
func (s *fakeTelemetrySubmitter) Submit(context.Context, deviceauth.Identity, string, []byte, device.TelemetryBatchInput) (device.TelemetryBatchResponse, bool, error) {
s.calls++
return s.response, s.replayed, s.err
}
func TestSubmitDeviceTelemetryAcceptsTypedBatch(t *testing.T) {
service := &fakeTelemetrySubmitter{response: device.TelemetryBatchResponse{
AcceptedCount: 2, DesiredConfigRevision: 2, Actions: []device.TelemetryAction{{Type: "refresh_config"}},
}, replayed: true}
body := `{"events":[{"event_key":"status-101","sequence_no":101,"occurred_at":"2026-08-23T12:00:00Z","event_type":"status","status":{"firmware_version":"1.0.1","reported_config_revision":1,"network":{"type":"wifi","rssi":-50},"rtc":{"joined":false,"uplink":false,"downlink":false},"recording_queue":{"pending_segments":2,"failed_segments":1,"last_failure_code":"MEDIA_UPLOAD_TIMEOUT","last_failure_at":"2026-08-23T11:59:58Z"}}},{"event_key":"error-102","sequence_no":102,"occurred_at":"2026-08-23T12:00:01Z","event_type":"error","error":{"code":"RTC_RECONNECTING","severity":"warning"}}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/telemetry:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "telemetry-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
SubmitDeviceTelemetryHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || service.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" || !containsText(recorder.Body.String(), "refresh_config") {
t.Fatalf("status=%d calls=%d replay=%q body=%s", recorder.Code, service.calls, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestSubmitDeviceTelemetryRejectsMixedPayloadAndSensitiveCommandResult(t *testing.T) {
service := &fakeTelemetrySubmitter{}
body := `{"events":[{"event_key":"command-1","sequence_no":1,"occurred_at":"2026-08-23T12:00:00Z","event_type":"command_result","error":{"code":"X","severity":"error"},"command_result":{"command_id":"10000000-0000-0000-0000-000000000001","status":"SUCCEEDED","result_summary":{"api_token":"must-not-pass"}}}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/telemetry:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "telemetry-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
SubmitDeviceTelemetryHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || service.calls != 0 {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, service.calls, recorder.Body.String())
}
}
func TestSubmitDeviceTelemetryReturnsStructuredEventConflict(t *testing.T) {
service := &fakeTelemetrySubmitter{err: &device.TelemetryConflictError{Conflicts: []device.TelemetryConflict{{EventKey: "error-1", SequenceNo: 1, Reason: "EVENT_KEY_OR_SEQUENCE_ALREADY_USED"}}}}
body := `{"events":[{"event_key":"error-1","sequence_no":1,"occurred_at":"2026-08-23T12:00:00Z","event_type":"error","error":{"code":"RTC_FAILED","severity":"error"}}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/telemetry:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "telemetry-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
SubmitDeviceTelemetryHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !containsText(recorder.Body.String(), "TELEMETRY_EVENT_CONFLICT") || !containsText(recorder.Body.String(), "EVENT_KEY_OR_SEQUENCE_ALREADY_USED") {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}