This commit is contained in:
@@ -34,7 +34,7 @@ func TestAlertHandlersWriteAndReadLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
routeKey := "http-alert-route-" + uuid.NewString()
|
||||
deliveryTestKey := "http-alert-delivery-test-" + uuid.NewString()
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestBillingHandlersPriceQuotaAndSummary(t *testing.T) {
|
||||
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"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
priceKey, quotaKey := "http-billing-price-"+uuid.NewString(), "http-billing-quota-"+uuid.NewString()
|
||||
sourceRef := "integration://http-billing/" + uuid.NewString()
|
||||
|
||||
@@ -488,6 +488,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
|
||||
})
|
||||
case errors.Is(err, orchestration.ErrDeviceNotBound):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_NOT_BOUND", "设备尚未绑定有效长者")
|
||||
case errors.Is(err,orchestration.ErrOnboardingState):
|
||||
WriteError(writer,request,http.StatusConflict,"ONBOARDING_STATE_CONFLICT","绑定状态或首通许可已变化,请同步当前设备状态")
|
||||
case errors.Is(err, orchestration.ErrConsentRequired):
|
||||
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
|
||||
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
|
||||
|
||||
@@ -47,7 +47,7 @@ func RequireDeviceActivation(allowed []string, next http.Handler) http.Handler {
|
||||
if state == "" {
|
||||
state = "ACTIVE"
|
||||
}
|
||||
if _, ok := allowedSet[state]; !ok {
|
||||
if _, ok := allowedSet[state]; !ok && !(identity.EnrollmentMode == "physical_confirm" && identity.ActivationTest) {
|
||||
WriteAPIError(w, r, http.StatusConflict, Error{Code: "DEVICE_ACTIVATION_REQUIRED", Message: "当前设备激活状态不能执行该操作", Details: map[string]any{"activation_state": state, "allowed_states": allowed}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
)
|
||||
@@ -50,6 +51,8 @@ func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_AUTHORIZATION_REQUIRED", "请先由当前家庭授权解绑,随后再执行设备重置")
|
||||
case errors.Is(err, devicereset.ErrPendingTranscripts):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
|
||||
case errors.Is(err, devicereset.ErrFirmwareInProgress):
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestDeviceFactoryResetHandlerReturnsClaimableState(t *testing.T) {
|
||||
ResetID: "10000000-0000-0000-0000-000000000901", DeviceID: "10000000-0000-0000-0000-000000000512",
|
||||
ActivationState: "CLAIMABLE", ResetCounter: 2, BindingRemoved: true,
|
||||
ProviderCredentialStatus: "SUSPENDED", LicenseAction: "RETAINED",
|
||||
Claim: devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
|
||||
Claim: &devicereset.Claim{Code: "ABCD-EFGH-IJKL-MNOP", IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour)},
|
||||
}}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestDiagnosticHandlersSearchRefreshJobsAndBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
deviceID := "10000000-0000-0000-0000-000000000511"
|
||||
refreshKey := "http-diagnostic-refresh-" + uuid.NewString()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
@@ -88,6 +89,8 @@ func ProvisionHardwareAgentCredentialHandler(service HardwareAgentProvisioningSe
|
||||
switch {
|
||||
case errors.Is(err, hardwareprovision.ErrUnavailable):
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE", Message: "平台尚未配置火山设备注册服务", Retryable: false})
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "ONBOARDING_ACTIVATION_REQUIRED", "请通过首次接入激活任务获取运行配置")
|
||||
case errors.Is(err, hardwareprovision.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山硬件标识不正确")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMissing):
|
||||
@@ -187,7 +190,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
|
||||
return
|
||||
}
|
||||
switch input.EventType {
|
||||
case "session_started", "session_ended", "session_failed", "session_updated":
|
||||
case "session_started", "session_ended", "session_failed", "session_updated", "first_audio_received":
|
||||
default:
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "不支持的硬件智能体事件类型")
|
||||
return
|
||||
@@ -204,7 +207,7 @@ func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler
|
||||
WriteError(writer, request, http.StatusNotFound, "CONVERSATION_NOT_FOUND", "通话不存在或不属于当前设备")
|
||||
case errors.Is(err, orchestration.ErrHardwareSessionProof):
|
||||
WriteError(writer, request, http.StatusUnauthorized, "HARDWARE_SESSION_PROOF_INVALID", "硬件智能体会话凭证无效")
|
||||
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict):
|
||||
case errors.Is(err, orchestration.ErrHardwareEventConflict), errors.Is(err, orchestration.ErrConversationStateConflict), errors.Is(err,orchestration.ErrOnboardingState):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_EVENT_CONFLICT", "硬件智能体事件与当前会话状态冲突")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_EVENT_UNAVAILABLE", Message: "暂时无法处理硬件智能体事件", Retryable: true})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func onboardingCustomerAuth(service *onboarding.Service, bearer, next http.Handler) http.Handler {
|
||||
if service == nil {
|
||||
return bearer
|
||||
}
|
||||
cookie := service.CustomerAuth(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
allowed := path == "/api/v1/me" || path == "/api/v1/households" || strings.HasPrefix(path, "/api/v1/households/") || strings.HasPrefix(path, "/api/v1/subjects/") || strings.HasPrefix(path, "/api/v1/devices/")
|
||||
if r.Header.Get("Authorization") == "" && allowed {
|
||||
cookie.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
bearer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -42,8 +42,8 @@ func TestOpsAccessHandlersCompleteTwoPersonAccessFlow(t *testing.T) {
|
||||
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"}
|
||||
requester := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: requester.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
|
||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||
consentSnapshotID, contextSnapshotID := uuid.NewString(), uuid.NewString()
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestOpsConfigHandlersPublishAndRollback(t *testing.T) {
|
||||
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"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
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()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
)
|
||||
@@ -268,6 +269,8 @@ func opsDevicePrincipal(w http.ResponseWriter, r *http.Request) (principal ident
|
||||
|
||||
func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "该设备使用生产凭据和按键确认流程,请使用首次接入管理入口")
|
||||
case errors.Is(err, opsdevice.ErrInvalid):
|
||||
writeValidation(w, r, "设备管理参数不正确", FieldError{Field: "device", Message: "设备字段或家庭/长者归属不正确"})
|
||||
case errors.Is(err, opsdevice.ErrAccessDenied):
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestOpsGateHandlersResourceReferencesAndDeviceGroups(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
deviceID := "10000000-0000-0000-0000-000000000511"
|
||||
resourceKey := "http-ops-gates-resource-" + uuid.NewString()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/media"
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"data-terminal-cloud/internal/opsaccess"
|
||||
"data-terminal-cloud/internal/opsconfig"
|
||||
"data-terminal-cloud/internal/opsdevice"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
)
|
||||
|
||||
type RouterOptions struct {
|
||||
OnboardingService *onboarding.Service
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
@@ -71,6 +73,9 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
if options.ServiceName == "api" && options.OnboardingService != nil {
|
||||
options.OnboardingService.RegisterRoutes(mux)
|
||||
}
|
||||
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{
|
||||
@@ -93,11 +98,13 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /internal/v1/tools/memory-search", MemoryToolSearchHandler(options.MemoryToolService, options.MemoryToolSigner, options.RateLimiter, logger))
|
||||
}
|
||||
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))))
|
||||
handler := UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))
|
||||
mux.Handle("GET /api/v1/me", onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, handler), handler))
|
||||
}
|
||||
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))
|
||||
limited := UserRateLimit(logger, options.RateLimiter, handler)
|
||||
return onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, limited), limited)
|
||||
}
|
||||
if options.WebEventService != nil {
|
||||
mux.Handle("GET /api/v1/events", authenticated(StreamWebEventsHandler(options.WebEventService)))
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/pagination"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
@@ -1882,6 +1883,8 @@ func writeETagError(writer http.ResponseWriter, request *http.Request, err error
|
||||
|
||||
func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, devicebinding.ErrConfirmationRequired):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "请先授权解绑,再从设备设置入口申请并按键确认绑定")
|
||||
case errors.Is(err, saas.ErrAccessDenied):
|
||||
WriteError(writer, request, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号无权访问该资源")
|
||||
case errors.Is(err, saas.ErrNotFound):
|
||||
|
||||
Reference in New Issue
Block a user