feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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:
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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", "设备管理操作失败")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user