feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
@@ -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):
+1 -1
View File
@@ -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()
+5 -2
View File
@@ -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()
+3
View File
@@ -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()
+9 -2
View File
@@ -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)))
+3
View File
@@ -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):