feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+4 -2
View File
@@ -488,8 +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.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):
@@ -498,6 +498,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
WriteError(writer, request, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED", "火山硬件智能体SDK低于当前准入版本,请先完成升级")
case errors.Is(err, orchestration.ErrConversationAlreadyActive):
WriteError(writer, request, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE", "设备已有未结束通话")
case errors.Is(err, orchestration.ErrCallContractRequired):
WriteError(writer, request, http.StatusUpgradeRequired, "CALL_MEMORY_PROTOCOL_REQUIRED", "记忆已发生关键变更,请使用支持 CALL-MEMORY-1.1 的设备固件")
case errors.Is(err, orchestration.ErrUsageQuotaExceeded):
WriteError(writer, request, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED", "客户组织或设备用量额度已达到上限,暂时不能创建新通话")
case errors.Is(err, orchestration.ErrRuntimeConfigUnavailable):
+41
View File
@@ -0,0 +1,41 @@
package httpapi
import (
"context"
"data-terminal-cloud/internal/identity"
"errors"
"net/http"
)
type profileUpdater interface {
UpdateDisplayName(context.Context, identity.Principal, string) error
}
func UpdateCustomerProfileHandler(repository profileUpdater) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
var input struct {
DisplayName string `json:"display_name"`
}
if !decodeWebJSON(w, r, &input, "请填写有效昵称") {
return
}
name, err := identity.ValidateDisplayName(input.DisplayName)
if err != nil {
writeValidation(w, r, "昵称须为1–80个字符,不能包含控制字符", FieldError{Field: "display_name", Message: "请填写有效昵称"})
return
}
if err = repository.UpdateDisplayName(r.Context(), principal, name); errors.Is(err, identity.ErrAccountNotFound) || errors.Is(err, identity.ErrAccessDenied) {
WriteError(w, r, http.StatusForbidden, "RESOURCE_ACCESS_DENIED", "当前账号不可修改")
return
} else if err != nil {
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "保存失败,请重试")
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]string{"display_name": name})
})
}
+50
View File
@@ -0,0 +1,50 @@
package httpapi
import (
"context"
"data-terminal-cloud/internal/identity"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type profileStub struct {
called bool
name string
principal identity.Principal
}
func (s *profileStub) UpdateDisplayName(_ context.Context, p identity.Principal, name string) error {
s.called = true
s.name = name
s.principal = p
return nil
}
func TestProfileHandlerAuthenticatesAndValidates(t *testing.T) {
for _, tc := range []struct {
name, body string
auth bool
want int
}{{"anonymous", `{"display_name":"家人"}`, false, 401}, {"empty", `{"display_name":" "}`, true, 400}, {"target injection", `{"display_name":"家人","user_id":"other"}`, true, 400}, {"valid", `{"display_name":" 家人 "}`, true, 200}} {
t.Run(tc.name, func(t *testing.T) {
repo := &profileStub{}
r := httptest.NewRequest(http.MethodPatch, "/api/v1/me/profile", strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
if tc.auth {
r = r.WithContext(identity.WithPrincipal(r.Context(), identity.Principal{Issuer: "test", Subject: "self"}))
}
w := httptest.NewRecorder()
UpdateCustomerProfileHandler(repo).ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status=%d body=%s", w.Code, w.Body)
}
if repo.called != (tc.want == 200) {
t.Fatal("invalid request mutated account")
}
if repo.called && (repo.name != "家人" || repo.principal.Subject != "self") {
t.Fatal("wrong account or nickname")
}
})
}
}
@@ -55,6 +55,8 @@ func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
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.ErrCallInProgress):
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_CALL_IN_PROGRESS", "设备正在通话,请结束后再恢复出厂")
case errors.Is(err, devicereset.ErrFirmwareInProgress):
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_FIRMWARE_IN_PROGRESS", "设备正在升级固件,暂时不能恢复出厂")
case errors.Is(err, devicereset.ErrResetCounterConflict):
+23
View File
@@ -0,0 +1,23 @@
package httpapi
import (
"data-terminal-cloud/internal/webevents"
"net/http"
"time"
)
func WebEventHistoryHandler(service *webevents.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := webPrincipal(w, r)
if !ok {
return
}
items, err := service.History(r.Context(), principal, time.Now().Add(-7*24*time.Hour), 200)
if err != nil {
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "近期动态读取失败,请重试")
return
}
w.Header().Set("Cache-Control", "no-store")
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items, "limit": 200, "retention_days": 7})
})
}
+133
View File
@@ -0,0 +1,133 @@
package httpapi
import (
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/saas"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"io"
"net/http"
"time"
)
func writeFleetError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, pgx.ErrNoRows) {
WriteError(w, r, 404, "FIRMWARE_NOT_FOUND", "固件或设备不存在,请刷新后重试")
return
}
var e *devicefleet.Error
if errors.As(err, &e) {
WriteError(w, r, 409, e.Code, e.Message)
} else {
writeSaaSError(w, r, err)
}
}
func FleetFirmwareHandler(s *saas.Service, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, public, ok := opsFirmwarePrincipal(w, r)
if !ok {
return
}
tenant, err := s.FleetTenant(r.Context(), p, public, r.Method == "POST")
if err != nil {
writeFleetError(w, r, err)
return
}
id := r.PathValue("id")
if id != "" && !validUUID(id) {
WriteError(w, r, 400, "INVALID_ID", "固件ID不正确")
return
}
var out any
switch action {
case "list":
var items []devicefleet.Release
items, err = s.Fleet.Releases(r.Context(), tenant)
out = map[string]any{"items": items}
case "models":
var items []string
items, err = s.Fleet.Models(r.Context(), tenant)
out = map[string]any{"items": items}
case "prepare":
var input devicefleet.ReleaseInput
if !decodeWebJSON(w, r, &input, "固件信息不正确") {
return
}
out, err = s.Fleet.Prepare(r.Context(), tenant, input)
case "finalize":
out, err = s.Fleet.Finalize(r.Context(), tenant, id)
case "download":
out, err = s.Fleet.Download(r.Context(), tenant, id)
}
if err != nil {
writeFleetError(w, r, err)
return
}
WriteJSON(w, r, 200, out)
})
}
func FleetDeviceHandler(s *saas.Service, upgrade bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
var out any
var err error
if upgrade {
var in struct {
Code int64 `json:"version_code"`
}
if !decodeWebJSON(w, r, &in, "目标版本顺序号不正确") {
return
}
out, err = s.FleetOTA(r.Context(), p, r.PathValue("id"), RequestID(r.Context()), in.Code)
} else {
tenant := r.Header.Get("X-Tenant-Id")
if tenant != "" && !validUUID(tenant) {
WriteError(w, r, 400, "INVALID_TENANT", "租户ID不正确")
return
}
out, err = s.FleetState(r.Context(), p, r.PathValue("id"), tenant)
}
if err != nil {
writeFleetError(w, r, err)
return
}
WriteJSON(w, r, 200, out)
})
}
func FleetFirmwareDownloadHandler(s *devicefleet.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
grant, err := s.ResolveDownload(r.Context(), r.PathValue("token"))
if errors.Is(err, devicefleet.ErrDownloadTicketUnavailable) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "firmware download unavailable", http.StatusServiceUnavailable)
return
}
object, err := s.OpenDownload(r.Context(), grant)
if err != nil {
http.Error(w, "firmware download unavailable", http.StatusServiceUnavailable)
return
}
defer object.Close()
serveFleetFirmwareContent(w, r, grant, object)
})
}
func serveFleetFirmwareContent(w http.ResponseWriter, r *http.Request, grant devicefleet.DownloadGrant, object io.ReadSeeker) {
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(20 * time.Minute))
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Disposition", `attachment; filename="firmware.bin"`)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("ETag", fmt.Sprintf(`"sha256-%s"`, grant.SHA256))
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "firmware.bin", grant.CreatedAt, object)
}
+30
View File
@@ -0,0 +1,30 @@
package httpapi
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
"data-terminal-cloud/internal/devicefleet"
)
func TestServeFleetFirmwareContentSupportsRangeAndHead(t *testing.T) {
grant := devicefleet.DownloadGrant{SHA256: "abcd", CreatedAt: time.Unix(1_700_000_000, 0)}
rangeRequest := httptest.NewRequest(http.MethodGet, "/f/token", nil)
rangeRequest.Header.Set("Range", "bytes=2-5")
rangeResponse := httptest.NewRecorder()
serveFleetFirmwareContent(rangeResponse, rangeRequest, grant, bytes.NewReader([]byte("firmware")))
if rangeResponse.Code != http.StatusPartialContent || rangeResponse.Body.String() != "rmwa" || rangeResponse.Header().Get("Content-Range") != "bytes 2-5/8" {
t.Fatalf("range status=%d body=%q content-range=%q", rangeResponse.Code, rangeResponse.Body.String(), rangeResponse.Header().Get("Content-Range"))
}
headRequest := httptest.NewRequest(http.MethodHead, "/f/token", nil)
headResponse := httptest.NewRecorder()
serveFleetFirmwareContent(headResponse, headRequest, grant, bytes.NewReader([]byte("firmware")))
if headResponse.Code != http.StatusOK || headResponse.Body.Len() != 0 || headResponse.Header().Get("Content-Length") != "8" {
t.Fatalf("head status=%d body=%q length=%q", headResponse.Code, headResponse.Body.String(), headResponse.Header().Get("Content-Length"))
}
}
+1 -1
View File
@@ -207,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), errors.Is(err,orchestration.ErrOnboardingState):
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})
+26
View File
@@ -0,0 +1,26 @@
package httpapi
import (
"data-terminal-cloud/internal/saas"
"net/http"
)
func SubjectHomeOverviewHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := resourcePrincipal(writer, request, "id")
if !ok {
return
}
if request.URL.RawQuery != "" {
writeValidation(writer, request, "首页概览不接受列表筛选参数")
return
}
result, err := service.SubjectHomeOverview(request.Context(), principal, request.PathValue("id"))
if err != nil {
writeSaaSError(writer, request, err)
return
}
writer.Header().Set("Cache-Control", "no-store")
WriteJSON(writer, request, http.StatusOK, result)
})
}
+45 -2
View File
@@ -11,9 +11,23 @@ func onboardingCustomerAuth(service *onboarding.Service, bearer, next http.Handl
return bearer
}
cookie := service.CustomerAuth(next)
operator := service.OperatorAuth(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 strings.HasPrefix(r.Header.Get("Authorization"), "Mini ") {
if operatorCookieRequest(r) {
operator.ServeHTTP(w, r)
return
}
if customerCookieRequest(r) {
cookie.ServeHTTP(w, r)
return
}
}
if r.Header.Get("Authorization") == "" && operatorCookieRequest(r) {
operator.ServeHTTP(w, r)
return
}
allowed := customerCookieRequest(r)
if r.Header.Get("Authorization") == "" && allowed {
cookie.ServeHTTP(w, r)
return
@@ -21,3 +35,32 @@ func onboardingCustomerAuth(service *onboarding.Service, bearer, next http.Handl
bearer.ServeHTTP(w, r)
})
}
func operatorCookieRequest(r *http.Request) bool {
path := r.URL.Path
if path == "/api/v1/ops" || strings.HasPrefix(path, "/api/v1/ops/") {
return true
}
if path == "/api/v1/content-models" || strings.HasPrefix(path, "/api/v1/content-models/") {
return r.Method != http.MethodGet || r.Header.Get("X-Login-Portal") == "operator"
}
return (path == "/api/v1/me" || path == "/api/v1/events") && r.Header.Get("X-Login-Portal") == "operator"
}
func customerCookieRequest(r *http.Request) bool {
// The portal reads the active model after loading a household. Model writes
// remain under the original operator authentication and authorization.
return customerCookiePath(r.URL.Path) || (r.Method == http.MethodGet && r.URL.Path == "/api/v1/content-models")
}
func customerCookiePath(path string) bool {
// Explicit customer resources only. Operations, provider and device protocol
// endpoints still require their original authentication.
for _, resource := range []string{"me", "households", "subjects", "devices", "device-claims", "member-invitations", "events", "conversations", "stories", "memoirs", "memories", "privacy-requests", "exports", "sharing-grants", "recording-access-grants", "media-segments", "firmware-releases", "firmware-rollouts"} {
root := "/api/v1/" + resource
if path == root || strings.HasPrefix(path, root+"/") {
return true
}
}
return false
}
@@ -0,0 +1,86 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/onboarding"
)
func TestCustomerModelReadDoesNotGrantOperatorWrites(t *testing.T) {
for _, tc := range []struct {
method, path string
allowed bool
}{
{"GET", "/api/v1/content-models", true},
{"POST", "/api/v1/content-models", false},
{"POST", "/api/v1/content-models/123/activate", false},
{"GET", "/api/v1/content-models/123", false},
{"GET", "/api/v1/ops/content-models", false},
} {
if customerCookieRequest(httptest.NewRequest(tc.method, tc.path, nil)) != tc.allowed {
t.Fatalf("wrong auth boundary: %s %s", tc.method, tc.path)
}
}
}
func TestAlertRoutesSelectOperatorCookieAuthentication(t *testing.T) {
router := NewRouter(RouterOptions{ServiceName: "api", TokenVerifier: &identity.OIDCVerifier{}, AlertService: &alerts.Service{}, OnboardingService: &onboarding.Service{}})
for _, path := range []string{"alert-routing-policies", "alerts", "alert-silences", "alert-delivery-tests"} {
r := httptest.NewRequest("GET", "https://platform.example/api/v1/ops/"+path, nil)
r.AddCookie(&http.Cookie{Name: "__Host-platform-onboarding", Value: "customer-cookie"})
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if w.Code != 401 || body.Code != "LOGIN_REQUIRED" {
t.Fatalf("%s must require an operator cookie: %s", path, w.Body.String())
}
}
}
func TestCustomerCookieRouteBoundary(t *testing.T) {
for _, path := range []string{"/api/v1/memoirs/query", "/api/v1/memoirs/generate", "/api/v1/memoirs/import", "/api/v1/memoirs/illustrations/123"} {
if !customerCookiePath(path) {
t.Errorf("memoir customer auth unavailable: %s", path)
}
}
for _, path := range []string{"/api/v1/me", "/api/v1/devices", "/api/v1/households/123", "/api/v1/events", "/api/v1/memories/mutations", "/api/v1/stories/123", "/api/v1/conversations/123/turns", "/api/v1/privacy-requests/123", "/api/v1/exports/123", "/api/v1/member-invitations/accept"} {
if !customerCookiePath(path) {
t.Errorf("customer route not enabled: %s", path)
}
}
for _, path := range []string{"/api/v1/ops/devices", "/api/v1/content-models", "/api/v1/device/calls/prepare", "/api/v1/device/v3/sessions", "/api/v1/memories-admin", "/api/v1/devices-admin", "/internal/v1/tools/memory-search", "/callbacks/v1/vertc/voice-chat"} {
if customerCookiePath(path) {
t.Errorf("cookie crossed authentication boundary: %s", path)
}
}
}
func TestOperatorCookieRouteBoundary(t *testing.T) {
for _, path := range []string{"/api/v1/ops/devices", "/api/v1/ops/device-inventory", "/api/v1/content-models", "/api/v1/me", "/api/v1/events"} {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("X-Login-Portal", "operator")
if !operatorCookieRequest(r) {
t.Errorf("operator route unavailable: %s", path)
}
}
for _, path := range []string{"/api/v1/ops-admin", "/api/v1/device/calls/prepare", "/api/v1/web/login/password", "/callbacks/v1/vertc/voice-chat", "/api/v1/households", "/internal/v1/tools/memory-search"} {
r := httptest.NewRequest("POST", path, nil)
r.Header.Set("X-Login-Portal", "operator")
if operatorCookieRequest(r) {
t.Errorf("operator cookie crossed boundary: %s", path)
}
}
if operatorCookieRequest(httptest.NewRequest("GET", "/api/v1/me", nil)) {
t.Fatal("operator identity selected in customer portal")
}
}
+4 -20
View File
@@ -50,31 +50,13 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
return
}
var body struct {
Model string `json:"model"`
HardwareRevision string `json:"hardware_revision"`
MinimumSecurityVersion string `json:"minimum_security_version"`
MinimumFirmwareVersion string `json:"minimum_firmware_version"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
VoiceRuntime string `json:"voice_runtime"`
HardwareAgentBotID string `json:"hardware_agent_bot_id"`
HardwareAgentTransport string `json:"hardware_agent_transport"`
HardwareAgentMinimumSDKVersion string `json:"hardware_agent_minimum_sdk_version"`
InitialActivationState string `json:"activation_state"`
Model string `json:"model"`
}
if !decodeWebJSON(w, r, &body, "设备登记参数不正确") {
return
}
if (body.HouseholdID != "" && !validUUID(body.HouseholdID)) || (body.SubjectID != "" && !validUUID(body.SubjectID)) {
writeValidation(w, r, "设备登记参数不正确", FieldError{Field: "assignment", Message: "家庭和长者必须是有效UUID"})
return
}
result, err := service.Create(r.Context(), principal, tenantID, RequestID(r.Context()), opsdevice.CreateInput{
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
MinimumFirmwareVersion: body.MinimumFirmwareVersion, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
VoiceRuntime: body.VoiceRuntime, HardwareAgentBotID: body.HardwareAgentBotID, HardwareAgentTransport: body.HardwareAgentTransport,
HardwareAgentMinimumSDKVersion: body.HardwareAgentMinimumSDKVersion,
InitialActivationState: body.InitialActivationState,
Model: body.Model,
})
if err != nil {
writeOpsDeviceError(w, r, err)
@@ -285,6 +267,8 @@ func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_CONFLICT", "设备激活状态已变化或不允许执行该迁移")
case errors.Is(err, opsdevice.ErrActivationNotReady):
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_NOT_READY", "设备绑定或运行凭据尚未就绪,不能进入 ACTIVE")
case errors.Is(err, opsdevice.ErrProvisioningUnavailable):
WriteError(w, r, http.StatusServiceUnavailable, "DEVICE_PROVISIONING_UNAVAILABLE", "设备烧录配置未就绪,请联系平台管理员")
default:
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "设备管理操作失败")
}
+36 -1
View File
@@ -8,12 +8,14 @@ import (
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/buildinfo"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/contentmodel"
"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/memoir"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/onboarding"
"data-terminal-cloud/internal/opsaccess"
@@ -25,6 +27,8 @@ import (
)
type RouterOptions struct {
MemoirService *memoir.Service
CallMemoryService *callmemory.Service
OnboardingService *onboarding.Service
ServiceName string
Logger *slog.Logger
@@ -75,6 +79,10 @@ func NewRouter(options RouterOptions) http.Handler {
mux := http.NewServeMux()
if options.ServiceName == "api" && options.OnboardingService != nil {
options.OnboardingService.RegisterRoutes(mux)
if options.CallMemoryService != nil {
options.CallMemoryService.RegisterDeviceRoutes(mux, options.OnboardingService.AuthenticateCallRequest)
options.CallMemoryService.RegisterDirectRoutes(mux, options.OnboardingService.AuthenticateCallRequest)
}
}
mux.Handle("GET /metrics", MetricsHandler())
mux.HandleFunc("GET /health/live", func(writer http.ResponseWriter, request *http.Request) {
@@ -94,20 +102,32 @@ func NewRouter(options RouterOptions) http.Handler {
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.SaaSService != nil && options.SaaSService.Fleet != nil {
mux.Handle("GET /f/{token}", FleetFirmwareDownloadHandler(options.SaaSService.Fleet))
}
if options.ServiceName == "api" && options.MemoryToolService != nil && options.MemoryToolSigner != nil {
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 {
handler := UserRateLimit(logger, options.RateLimiter, CurrentUserHandler(options.IdentityRepository))
mux.Handle("GET /api/v1/me", onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, handler), handler))
profileHandler := UserRateLimit(logger, options.RateLimiter, UpdateCustomerProfileHandler(options.IdentityRepository))
mux.Handle("PATCH /api/v1/me/profile", onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, profileHandler), profileHandler))
}
if options.ServiceName == "api" && options.TokenVerifier != nil && options.SaaSService != nil {
authenticated := func(handler http.Handler) http.Handler {
limited := UserRateLimit(logger, options.RateLimiter, handler)
return onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, limited), limited)
}
if options.CallMemoryService != nil {
options.CallMemoryService.RegisterMemoryRoutes(mux, authenticated)
}
if options.MemoirService != nil {
options.MemoirService.RegisterRoutes(mux, authenticated)
}
if options.WebEventService != nil {
mux.Handle("GET /api/v1/events", authenticated(StreamWebEventsHandler(options.WebEventService)))
mux.Handle("GET /api/v1/events/history", authenticated(WebEventHistoryHandler(options.WebEventService)))
}
if options.SharingService != nil {
mux.Handle("GET /api/v1/sharing-grants", authenticated(ListSharingGrantsHandler(options.SharingService)))
@@ -203,6 +223,7 @@ func NewRouter(options RouterOptions) http.Handler {
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("POST /api/v1/subjects/{id}/voice-agent", authenticated(EnsureSubjectVoiceAgentHandler(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)))
@@ -217,6 +238,14 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("GET /api/v1/devices/{id}/binding-impact", authenticated(GetDeviceBindingImpactHandler(options.SaaSService)))
mux.Handle("POST /api/v1/devices/{id}/binding-change", authenticated(ChangeDeviceBindingHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/presence", authenticated(GetDevicePresenceHandler(options.SaaSService)))
mux.Handle("GET /api/v1/devices/{id}/status", authenticated(FleetDeviceHandler(options.SaaSService, false)))
mux.Handle("GET /api/v1/devices/{id}/firmware", authenticated(FleetDeviceHandler(options.SaaSService, false)))
mux.Handle("POST /api/v1/devices/{id}/ota", authenticated(FleetDeviceHandler(options.SaaSService, true)))
mux.Handle("GET /api/v1/ops/device-firmware", authenticated(FleetFirmwareHandler(options.SaaSService, "list")))
mux.Handle("POST /api/v1/ops/device-firmware", authenticated(FleetFirmwareHandler(options.SaaSService, "prepare")))
mux.Handle("GET /api/v1/ops/device-firmware/models", authenticated(FleetFirmwareHandler(options.SaaSService, "models")))
mux.Handle("POST /api/v1/ops/device-firmware/{id}/finalize", authenticated(FleetFirmwareHandler(options.SaaSService, "finalize")))
mux.Handle("GET /api/v1/ops/device-firmware/{id}/download", authenticated(FleetFirmwareHandler(options.SaaSService, "download")))
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)))
@@ -253,6 +282,7 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("GET /api/v1/ops/firmware-rollouts/{id}", authenticated(GetOpsFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("POST /api/v1/ops/firmware-rollouts/{id}/pause", authenticated(PauseOpsFirmwareRolloutHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/conversations", authenticated(ListSubjectConversationsHandler(options.SaaSService)))
mux.Handle("GET /api/v1/subjects/{id}/home-overview", authenticated(SubjectHomeOverviewHandler(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 {
@@ -275,6 +305,7 @@ func NewRouter(options RouterOptions) http.Handler {
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/households/{id}/privacy-requests", authenticated(ListHouseholdPrivacyRequestsHandler(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)))
@@ -284,7 +315,8 @@ func NewRouter(options RouterOptions) http.Handler {
}
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))
limited := UserRateLimit(logger, options.RateLimiter, handler)
return onboardingCustomerAuth(options.OnboardingService, BearerAuth(options.TokenVerifier, limited), limited)
}
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)))
@@ -356,6 +388,9 @@ func NewRouter(options RouterOptions) http.Handler {
mux.Handle("POST /device/v3/agent-sessions/{id}/turns:batch", activeDevice(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
}
}
if options.ServiceName == "callbacks" && options.CallMemoryService != nil {
mux.HandleFunc("POST /callbacks/v1/volcengine/chat", options.CallMemoryService.DirectCallback)
}
if options.ServiceName == "callbacks" && len(options.ProviderCallbackSecret) > 0 && options.ProviderCallbackProcessor != nil {
if len(options.NativeSubtitleSecret) >= 32 && options.NativeSubtitleProcessor != nil {
mux.Handle("POST /callbacks/v1/volcengine/subtitles", NativeSubtitleCallbackHandler(options.NativeSubtitleSecret, options.NativeSubtitleProcessor))
+55 -1
View File
@@ -12,6 +12,7 @@ import (
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/devicebinding"
"data-terminal-cloud/internal/deviceconfig"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/saas"
@@ -421,6 +422,25 @@ func GetSubjectHandler(service *saas.Service) http.Handler {
})
}
func EnsureSubjectVoiceAgentHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := resourcePrincipal(w, r, "id")
if !ok {
return
}
info, err := service.EnsureSubjectVoiceAgent(r.Context(), principal, r.PathValue("id"))
if err != nil {
if info.Status == "FAILED" {
WriteAPIError(w, r, http.StatusBadGateway, Error{Code: "VOICE_AGENT_CREATE_FAILED", Message: info.Message})
return
}
writeSaaSError(w, r, err)
return
}
WriteJSON(w, r, http.StatusOK, info)
})
}
func GetConversationReadinessHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := resourcePrincipal(writer, request, "id")
@@ -907,7 +927,7 @@ func PatchDeviceTwinHandler(service *saas.Service) http.Handler {
writeValidation(writer, request, "设备配置包含禁止字段", FieldError{Field: field, Message: "不得下发密钥、令牌、口令或凭据"})
return
}
allowed := map[string]bool{"volume": true, "interaction": true, "upload": true, "rtc": true, "policy_refs": true}
allowed := map[string]bool{"power": true, "identity": true, "volume": true, "interaction": true, "upload": true, "rtc": true, "policy_refs": true}
for key := range input.DesiredConfig {
if !allowed[key] {
writeValidation(writer, request, "设备配置参数不正确", FieldError{Field: "desired_config." + key, Message: "不是允许下发的非敏感配置分类"})
@@ -1398,6 +1418,21 @@ func DeleteMemoryHandler(service *saas.Service) http.Handler {
})
}
func ListHouseholdPrivacyRequestsHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := resourcePrincipal(writer, request, "id")
if !ok {
return
}
items, err := service.ListHouseholdPrivacyRequests(request.Context(), principal, request.PathValue("id"))
if err != nil {
writeSaaSError(writer, request, err)
return
}
WriteJSON(writer, request, http.StatusOK, map[string]any{"items": items})
})
}
func ListPrivacyRequestsHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := resourcePrincipal(writer, request, "id")
@@ -1703,6 +1738,7 @@ func validateSubjectContext(profile, preferences map[string]any) []FieldError {
result = append(result, validateOptionalContextList(profile, "interests", "profile_data.interests", 8, 50)...)
result = append(result, validateOptionalContextString(preferences, "conversation_goal", "conversation_preferences.conversation_goal", 200)...)
result = append(result, validateOptionalContextList(preferences, "preferred_topics", "conversation_preferences.preferred_topics", 8, 50)...)
result = append(result, validateOptionalContextList(preferences, "avoided_topics", "conversation_preferences.avoided_topics", 8, 50)...)
if preferences != nil {
if raw, exists := preferences["pace"]; exists {
pace, ok := raw.(string)
@@ -1846,6 +1882,20 @@ func forbiddenDeviceConfigField(value any, path string) (string, bool) {
}
func validateDesiredDeviceConfig(config map[string]any) []FieldError {
if value, exists := config["power"]; exists {
object, ok := value.(map[string]any)
mode, isString := object["mode"].(string)
if !ok || len(object) != 1 || !isString || (mode != "active" && mode != "standby") {
return []FieldError{{Field: "desired_config.power.mode", Message: "必须是active或standby"}}
}
}
if value, exists := config["identity"]; exists {
object, ok := value.(map[string]any)
name, isString := object["display_name"].(string)
if !ok || len(object) != 1 || !isString || !deviceconfig.ValidDisplayName(name) {
return []FieldError{{Field: "desired_config.identity.display_name", Message: "名称最多96字节,不含首尾空白或控制字符;留空恢复默认名称"}}
}
}
volume, exists := config["volume"]
if !exists {
return nil
@@ -1883,6 +1933,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, saas.ErrDevicePowerUnsupported):
WriteError(writer, request, http.StatusConflict, "DEVICE_POWER_UNSUPPORTED", "设备尚未确认支持远程待机,请先更新固件")
case errors.Is(err, devicebinding.ErrConfirmationRequired):
WriteError(writer, request, http.StatusConflict, "DEVICE_CONFIRMATION_REQUIRED", "请先授权解绑,再从设备设置入口申请并按键确认绑定")
case errors.Is(err, saas.ErrAccessDenied):
@@ -1895,6 +1947,8 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
WriteError(writer, request, http.StatusConflict, "EFFECTIVE_CONSENT_EXISTS", "该类型已有当前有效的授权")
case errors.Is(err, saas.ErrPolicyVersionMismatch):
WriteError(writer, request, http.StatusConflict, "POLICY_VERSION_MISMATCH", "授权政策版本已经更新,请重新阅读并确认")
case errors.Is(err, saas.ErrInitialConsentInvalid):
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "请逐项选择授权并确认用途")
case errors.Is(err, saas.ErrDeviceClaimInvalid):
WriteError(writer, request, http.StatusConflict, "DEVICE_CLAIM_INVALID", "设备绑定码无效、已过期或已撤销")
case errors.Is(err, saas.ErrDeviceClaimUsed):
+17
View File
@@ -24,6 +24,7 @@ func TestSaaSInputHelpers(t *testing.T) {
}
validProfile := map[string]any{"background": "铁路工作经历", "interests": []any{"老照片", "家乡变化"}}
validPreferences := map[string]any{"conversation_goal": "聊聊第一次工作", "pace": "slow", "preferred_topics": []any{"工作经历"}}
validPreferences["avoided_topics"] = []any{"收入"}
if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", validProfile, validPreferences); len(fields) != 0 {
t.Fatalf("valid conversation context fields=%#v", fields)
}
@@ -32,6 +33,9 @@ func TestSaaSInputHelpers(t *testing.T) {
if fields := validateSubject("长者", "zh-CN", "Asia/Shanghai", invalidProfile, invalidPreferences); len(fields) != 5 {
t.Fatalf("invalid conversation context fields=%#v", fields)
}
if fields := validateSubjectContext(map[string]any{}, map[string]any{"avoided_topics": []any{"收入", 42}}); len(fields) != 1 || fields[0].Field != "conversation_preferences.avoided_topics" {
t.Fatal("invalid boundary accepted", 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")
}
@@ -57,6 +61,10 @@ func TestSaaSInputHelpers(t *testing.T) {
{"volume": map[string]any{"speaker": float64(65)}},
{"volume": map[string]any{"speaker": float64(100)}},
{"interaction": map[string]any{"wake_word": "你好"}},
{"identity": map[string]any{"display_name": "客厅陪伴设备"}},
{"identity": map[string]any{"display_name": ""}},
{"power": map[string]any{"mode": "standby"}},
{"power": map[string]any{"mode": "active"}},
} {
if fields := validateDesiredDeviceConfig(config); len(fields) != 0 {
t.Fatalf("valid desired device config=%#v fields=%#v", config, fields)
@@ -64,6 +72,15 @@ func TestSaaSInputHelpers(t *testing.T) {
}
for _, config := range []map[string]any{
{"volume": "loud"},
{"identity": nil},
{"power": nil},
{"power": map[string]any{"mode": "off"}},
{"power": map[string]any{"mode": "standby", "unknown": true}},
{"identity": map[string]any{}},
{"identity": map[string]any{"display_name": 1}},
{"identity": map[string]any{"display_name": "a", "unknown": true}},
{"identity": map[string]any{"display_name": "首尾空格 "}},
{"identity": map[string]any{"display_name": strings.Repeat("名", 33)}},
{"volume": map[string]any{"speaker": float64(-1)}},
{"volume": map[string]any{"speaker": 50.5}},
{"volume": map[string]any{"speaker": float64(101)}},
@@ -96,6 +96,17 @@ func TestSaaSHouseholdAndSubjectWriteHandlers(t *testing.T) {
t.Fatal(err)
}
subjectID = subjectResponse.Data.ID
initialConsents, err := service.ListConsents(context.Background(), principal, subjectID)
if err != nil || len(initialConsents) != 0 {
t.Fatalf("omitted consent selection granted permissions: %+v err=%v", initialConsents, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/subjects", `{"preferred_name":"未确认授权","language":"zh-CN","timezone":"Asia/Shanghai","profile_data":{},"conversation_preferences":{},"initial_consents":{"types":["personalized_memory"],"confirmed":false}}`, principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
CreateSubjectHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unconfirmed selection status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = authenticatedRequest(http.MethodPatch, "/api/v1/subjects/"+subjectID, `{"preferred_name":"HTTP集成长者-修改"}`, principal)
request.SetPathValue("id", subjectID)
@@ -0,0 +1,15 @@
package httpapi
import "testing"
func TestSubjectCreateAllowsEmptyOptionalContext(t *testing.T) {
for _, value := range []string{"", " "} {
fields := validateSubject("长者", "zh-CN", "Asia/Shanghai",
map[string]any{"background": value, "interests": []any{}},
map[string]any{"conversation_goal": value, "pace": "slow", "preferred_topics": []any{}, "avoided_topics": []any{}},
)
if len(fields) != 0 {
t.Fatalf("optional background and goal %q should be accepted: %#v", value, fields)
}
}
}