@@ -492,6 +492,8 @@ func writeCreateConversationError(writer http.ResponseWriter, request *http.Requ
|
||||
WriteError(writer, request, http.StatusForbidden, "CONSENT_REQUIRED", "缺少本次语音通话所需授权")
|
||||
case errors.Is(err, orchestration.ErrFirmwareUpdateRequired):
|
||||
WriteError(writer, request, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED", "设备固件低于当前通话最低版本,请先完成升级")
|
||||
case errors.Is(err, orchestration.ErrHardwareSDKUpdateRequired):
|
||||
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.ErrUsageQuotaExceeded):
|
||||
|
||||
@@ -111,6 +111,7 @@ func TestConversationErrorWritersExposeStableStateAndRetryContracts(t *testing.T
|
||||
{"create unbound", writeCreateConversationError, orchestration.ErrDeviceNotBound, http.StatusConflict, "DEVICE_NOT_BOUND"},
|
||||
{"create consent", writeCreateConversationError, orchestration.ErrConsentRequired, http.StatusForbidden, "CONSENT_REQUIRED"},
|
||||
{"create firmware", writeCreateConversationError, orchestration.ErrFirmwareUpdateRequired, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED"},
|
||||
{"create hardware SDK", writeCreateConversationError, orchestration.ErrHardwareSDKUpdateRequired, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED"},
|
||||
{"create active", writeCreateConversationError, orchestration.ErrConversationAlreadyActive, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE"},
|
||||
{"create quota", writeCreateConversationError, orchestration.ErrUsageQuotaExceeded, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED"},
|
||||
{"create runtime config", writeCreateConversationError, orchestration.ErrRuntimeConfigUnavailable, http.StatusServiceUnavailable, "RUNTIME_CONFIG_UNAVAILABLE"},
|
||||
|
||||
@@ -31,3 +31,41 @@ func DeviceAuth(verifier DeviceRequestVerifier, next http.Handler) http.Handler
|
||||
next.ServeHTTP(writer, request.WithContext(deviceauth.WithIdentity(request.Context(), identity)))
|
||||
})
|
||||
}
|
||||
|
||||
func RequireDeviceActivation(allowed []string, next http.Handler) http.Handler {
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, state := range allowed {
|
||||
allowedSet[state] = struct{}{}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
state := identity.ActivationState
|
||||
if state == "" {
|
||||
state = "ACTIVE"
|
||||
}
|
||||
if _, ok := allowedSet[state]; !ok {
|
||||
WriteAPIError(w, r, http.StatusConflict, Error{Code: "DEVICE_ACTIVATION_REQUIRED", Message: "当前设备激活状态不能执行该操作", Details: map[string]any{"activation_state": state, "allowed_states": allowed}})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func RequireHardwareLicense(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
if identity.VoiceRuntime == "volc_hardware_agent" && !identity.HardwareLicenseReady {
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_REQUIRED", "设备没有已激活且未到期的硬件智能体License")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,53 @@ func TestDeviceAuthDoesNotExposeCredentialFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDeviceActivation(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := RequireDeviceActivation([]string{"ACTIVE"}, next)
|
||||
|
||||
activeRequest := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
activeRequest = activeRequest.WithContext(deviceauth.WithIdentity(activeRequest.Context(), deviceauth.Identity{ActivationState: "ACTIVE"}))
|
||||
activeRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(activeRecorder, activeRequest)
|
||||
if activeRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("active status = %d", activeRecorder.Code)
|
||||
}
|
||||
|
||||
boundRequest := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
boundRequest = boundRequest.WithContext(deviceauth.WithIdentity(boundRequest.Context(), deviceauth.Identity{ActivationState: "BOUND"}))
|
||||
boundRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(boundRecorder, boundRequest)
|
||||
if boundRecorder.Code != http.StatusConflict || !containsText(boundRecorder.Body.String(), "DEVICE_ACTIVATION_REQUIRED") {
|
||||
t.Fatalf("bound response = %d %s", boundRecorder.Code, boundRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireHardwareLicense(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := RequireHardwareLicense(next)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
identity deviceauth.Identity
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "hardware license active", identity: deviceauth.Identity{VoiceRuntime: "volc_hardware_agent", HardwareLicenseReady: true}, status: http.StatusNoContent},
|
||||
{name: "hardware license missing", identity: deviceauth.Identity{VoiceRuntime: "volc_hardware_agent"}, status: http.StatusConflict, code: "HARDWARE_LICENSE_REQUIRED"},
|
||||
{name: "legacy runtime unaffected", identity: deviceauth.Identity{VoiceRuntime: "legacy_voicechat"}, status: http.StatusNoContent},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/agent-sessions", nil)
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), testCase.identity))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != testCase.status || (testCase.code != "" && !containsText(recorder.Body.String(), testCase.code)) {
|
||||
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(value, part string) bool {
|
||||
for index := 0; index+len(part) <= len(value); index++ {
|
||||
if value[index:index+len(part)] == part {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
)
|
||||
|
||||
type DeviceFactoryResetService interface {
|
||||
Reset(context.Context, deviceauth.Identity, string, string, []byte, devicereset.Input) (devicereset.Response, bool, error)
|
||||
}
|
||||
|
||||
func DeviceFactoryResetHandler(service DeviceFactoryResetService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("Pragma", "no-cache")
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input devicereset.Input
|
||||
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil || devicereset.Validate(input) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备恢复出厂参数不正确")
|
||||
return
|
||||
}
|
||||
response, replayed, err := service.Reset(request.Context(), identity, key, RequestID(request.Context()), rawBody, input)
|
||||
if replayed {
|
||||
writer.Header().Set("Idempotency-Replayed", "true")
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, devicereset.ErrPendingTranscripts):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_PENDING_TRANSCRIPTS", "设备仍有未上传的聊天记录")
|
||||
case errors.Is(err, devicereset.ErrFirmwareInProgress):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_FIRMWARE_IN_PROGRESS", "设备正在升级固件,暂时不能恢复出厂")
|
||||
case errors.Is(err, devicereset.ErrResetCounterConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_COUNTER_CONFLICT", "设备重置计数器不是递增值")
|
||||
case errors.Is(err, devicereset.ErrStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_RESET_STATE_CONFLICT", "当前设备状态不允许恢复出厂")
|
||||
case errors.Is(err, idempotency.ErrKeyConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "IDEMPOTENCY_KEY_CONFLICT", "重复请求识别码已用于不同的请求")
|
||||
case errors.Is(err, idempotency.ErrInProgress):
|
||||
WriteAPIError(writer, request, http.StatusConflict, Error{Code: "IDEMPOTENCY_IN_PROGRESS", Message: "相同恢复出厂请求仍在处理中", Retryable: true})
|
||||
case errors.Is(err, devicereset.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备恢复出厂参数不正确")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "DEVICE_RESET_FAILED", Message: "设备恢复出厂暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
)
|
||||
|
||||
type fakeDeviceFactoryResetService struct {
|
||||
response devicereset.Response
|
||||
replayed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func validDeviceFactoryResetBody() string {
|
||||
return fmt.Sprintf(`{"reset_id":"10000000-0000-0000-0000-000000000901","reset_counter":2,"occurred_at":%q,"trigger":"physical_long_press","firmware_version":"1.1.0","pending_turn_count":0}`,
|
||||
time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (f *fakeDeviceFactoryResetService) Reset(_ context.Context, _ deviceauth.Identity, _ string, _ string, _ []byte, _ devicereset.Input) (devicereset.Response, bool, error) {
|
||||
f.calls++
|
||||
return f.response, f.replayed, f.err
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerReturnsClaimableState(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{replayed: true, response: devicereset.Response{
|
||||
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)},
|
||||
}}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK || service.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" ||
|
||||
!strings.Contains(recorder.Body.String(), `"activation_state":"CLAIMABLE"`) || !strings.Contains(recorder.Body.String(), `"code":"ABCD-EFGH-IJKL-MNOP"`) {
|
||||
t.Fatalf("status=%d calls=%d headers=%v body=%s", recorder.Code, service.calls, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerRejectsInvalidInput(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(`{"reset_id":"bad"}`))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest || service.calls != 0 || !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") {
|
||||
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, service.calls, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerMapsPendingTranscripts(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{err: devicereset.ErrPendingTranscripts}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "DEVICE_RESET_PENDING_TRANSCRIPTS") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFactoryResetHandlerMapsIdempotencyConflict(t *testing.T) {
|
||||
service := &fakeDeviceFactoryResetService{err: errors.New("unexpected")}
|
||||
body := validDeviceFactoryResetBody()
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/factory-reset", strings.NewReader(body))
|
||||
request.Header.Set("Idempotency-Key", "factory-reset-key-0001")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "10000000-0000-0000-0000-000000000512"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
DeviceFactoryResetHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "DEVICE_RESET_FAILED") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
)
|
||||
|
||||
type HardwareAgentSessionService interface {
|
||||
CreateHardwareAgent(context.Context, deviceauth.Identity, string, string, []byte, orchestration.HardwareAgentCreateInput) (orchestration.HardwareAgentCreateResponse, bool, error)
|
||||
ProcessHardwareAgentEvent(context.Context, deviceauth.Identity, string, []byte, orchestration.HardwareAgentEventInput) (orchestration.HardwareAgentEventResponse, error)
|
||||
}
|
||||
|
||||
type HardwareAgentProvisioningService interface {
|
||||
Provision(context.Context, deviceauth.Identity, string, hardwareprovision.ProvisionInput) (hardwareprovision.ProvisionResponse, error)
|
||||
}
|
||||
|
||||
type HardwareAgentLicenseService interface {
|
||||
Acknowledge(context.Context, deviceauth.Identity, string, hardwarelicense.AcknowledgeInput) error
|
||||
}
|
||||
|
||||
func AcknowledgeHardwareAgentLicenseHandler(service HardwareAgentLicenseService) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(r.Context())
|
||||
if !ok {
|
||||
WriteError(w, r, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
LicenseRef string `json:"license_ref"`
|
||||
Digest string `json:"digest"`
|
||||
Result string `json:"result"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "License确认参数不正确") {
|
||||
return
|
||||
}
|
||||
err := service.Acknowledge(r.Context(), identity, RequestID(r.Context()), hardwarelicense.AcknowledgeInput{LicenseRef: body.LicenseRef, Digest: body.Digest, Result: body.Result, ErrorCode: body.ErrorCode})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, hardwarelicense.ErrAssignment), errors.Is(err, hardwarelicense.ErrAcknowledgement):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_ACK_CONFLICT", "License不属于当前设备或摘要不一致")
|
||||
case errors.Is(err, hardwarelicense.ErrExpired):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_EXPIRED", "License已经到期")
|
||||
default:
|
||||
WriteAPIError(w, r, http.StatusServiceUnavailable, Error{Code: "HARDWARE_LICENSE_ACK_FAILED", Message: "License确认暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"license_ref": body.LicenseRef, "status": strings.ToUpper(strings.TrimSpace(body.Result))})
|
||||
})
|
||||
}
|
||||
|
||||
func ProvisionHardwareAgentCredentialHandler(service HardwareAgentProvisioningService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("Pragma", "no-cache")
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var input hardwareprovision.ProvisionInput
|
||||
if err := decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山设备注册参数不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.Provision(request.Context(), identity, RequestID(request.Context()), input)
|
||||
if err != nil {
|
||||
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, hardwareprovision.ErrInvalid):
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "火山硬件标识不正确")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMissing):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_BINDING_REQUIRED", "设备尚未绑定火山硬件智能体")
|
||||
case errors.Is(err, hardwareprovision.ErrBindingMismatch):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_PRODUCT_MISMATCH", "设备绑定产品与平台注册产品不一致")
|
||||
case errors.Is(err, hardwareprovision.ErrHardwareMismatch):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_HARDWARE_MISMATCH", "硬件标识与已注册凭据不一致")
|
||||
case errors.Is(err, hardwareprovision.ErrActivationState):
|
||||
WriteError(writer, request, http.StatusConflict, "DEVICE_ACTIVATION_REQUIRED", "设备必须先完成用户绑定,才能申请火山运行凭据")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_PROVISIONING_FAILED", Message: "火山设备注册暂时失败", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateHardwareAgentSessionHandler(service HardwareAgentSessionService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(request.Header.Get("Idempotency-Key"))
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "缺少有效的重复请求识别码")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input orchestration.HardwareAgentCreateInput
|
||||
if err = decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体会话参数不正确")
|
||||
return
|
||||
}
|
||||
if input.DeviceTime.IsZero() || input.ConfigRevision < 1 || !input.Capabilities.HardwareAgent ||
|
||||
!input.Capabilities.TranscriptUpload || !input.Capabilities.DynamicSessionConfig ||
|
||||
!input.Capabilities.CredentialRotation || strings.TrimSpace(input.Capabilities.SDKVersion) == "" ||
|
||||
len(input.Capabilities.SDKVersion) > 80 || strings.TrimSpace(input.Capabilities.InputAudioFormat) == "" ||
|
||||
len(input.Capabilities.InputAudioFormat) > 80 || strings.TrimSpace(input.Capabilities.OutputAudioFormat) == "" ||
|
||||
len(input.Capabilities.OutputAudioFormat) > 80 || input.RequestedGoal != "open_conversation" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备时间、配置版本或硬件智能体能力不正确")
|
||||
return
|
||||
}
|
||||
validTransport := false
|
||||
for _, transport := range input.Capabilities.Transports {
|
||||
if transport != "high_quality" && transport != "low_load" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体传输模式不受支持")
|
||||
return
|
||||
}
|
||||
validTransport = true
|
||||
}
|
||||
if !validTransport {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "设备必须声明至少一种支持的硬件智能体传输模式")
|
||||
return
|
||||
}
|
||||
response, replayed, err := service.CreateHardwareAgent(request.Context(), identity, key, RequestID(request.Context()), rawBody, input)
|
||||
if replayed {
|
||||
writer.Header().Set("Idempotency-Replayed", "true")
|
||||
}
|
||||
if err != nil {
|
||||
writeCreateConversationError(writer, request, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusCreated, response)
|
||||
})
|
||||
}
|
||||
|
||||
func HardwareAgentEventHandler(service HardwareAgentSessionService) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
identity, ok := deviceauth.FromContext(request.Context())
|
||||
if !ok {
|
||||
WriteError(writer, request, http.StatusUnauthorized, "DEVICE_AUTHENTICATION_FAILED", "设备身份验证失败")
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
WriteError(writer, request, http.StatusBadRequest, "INVALID_REQUEST_BODY", "请求正文无法读取")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.DisallowUnknownFields()
|
||||
var input orchestration.HardwareAgentEventInput
|
||||
if err = decoder.Decode(&input); err != nil || ensureJSONEOF(decoder) != nil ||
|
||||
strings.TrimSpace(input.EventID) == "" || input.SequenceNo < 1 || input.OccurredAt.IsZero() ||
|
||||
input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || strings.TrimSpace(input.ProviderSessionRef) == "" ||
|
||||
strings.TrimSpace(input.SessionNonce) == "" {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "硬件智能体事件参数不正确")
|
||||
return
|
||||
}
|
||||
switch input.EventType {
|
||||
case "session_started", "session_ended", "session_failed", "session_updated":
|
||||
default:
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "不支持的硬件智能体事件类型")
|
||||
return
|
||||
}
|
||||
if input.EventType == "session_started" && (!input.DynamicConfigApplied || strings.TrimSpace(input.ProviderConnectionRef) == "" ||
|
||||
strings.TrimSpace(input.BotID) == "" || strings.TrimSpace(input.Transport) == "" || strings.TrimSpace(input.ConfigDigest) == "") {
|
||||
WriteError(writer, request, http.StatusBadRequest, "VALIDATION_FAILED", "会话启动前必须证明动态人设配置已生效")
|
||||
return
|
||||
}
|
||||
response, err := service.ProcessHardwareAgentEvent(request.Context(), identity, request.PathValue("id"), rawBody, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, orchestration.ErrConversationNotFound):
|
||||
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):
|
||||
WriteError(writer, request, http.StatusConflict, "HARDWARE_AGENT_EVENT_CONFLICT", "硬件智能体事件与当前会话状态冲突")
|
||||
default:
|
||||
WriteAPIError(writer, request, http.StatusServiceUnavailable, Error{Code: "HARDWARE_AGENT_EVENT_UNAVAILABLE", Message: "暂时无法处理硬件智能体事件", Retryable: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/hardwareprovision"
|
||||
)
|
||||
|
||||
type fakeHardwareProvisioner struct {
|
||||
response hardwareprovision.ProvisionResponse
|
||||
err error
|
||||
input hardwareprovision.ProvisionInput
|
||||
}
|
||||
|
||||
func (f *fakeHardwareProvisioner) Provision(_ context.Context, _ deviceauth.Identity, _ string, input hardwareprovision.ProvisionInput) (hardwareprovision.ProvisionResponse, error) {
|
||||
f.input = input
|
||||
return f.response, f.err
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerReturnsNoStoreCredential(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{response: hardwareprovision.ProvisionResponse{
|
||||
InstanceID: "instance", ProductKey: "product", DeviceName: "device", HardwareID: "hardware",
|
||||
DeviceSecret: "device-secret", CredentialRef: "credential-ref", CredentialVersion: 1,
|
||||
CredentialSource: "platform_dynamic_registration", IssuedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{ID: "internal", PublicID: "public", TenantID: "tenant", Status: "registered"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Pragma") != "no-cache" {
|
||||
t.Fatalf("credential response was cacheable: headers=%v", recorder.Header())
|
||||
}
|
||||
var response struct {
|
||||
Data hardwareprovision.ProvisionResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Data.DeviceSecret != "device-secret" || service.input.HardwareID != "hardware" {
|
||||
t.Fatalf("unexpected response/input: response=%+v input=%+v", response.Data, service.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerMapsUnavailable(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{err: hardwareprovision.ErrUnavailable}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "HARDWARE_AGENT_PROVISIONING_UNAVAILABLE") {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionHardwareAgentCredentialHandlerRejectsHardwareMismatch(t *testing.T) {
|
||||
service := &fakeHardwareProvisioner{err: hardwareprovision.ErrHardwareMismatch}
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v3/hardware-agent-credentials:provision", strings.NewReader(`{"hardware_id":"hardware"}`))
|
||||
request.Header.Set("Idempotency-Key", "provision-request-123456")
|
||||
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ProvisionHardwareAgentCredentialHandler(service).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "HARDWARE_AGENT_HARDWARE_MISMATCH") {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,17 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备登记参数不正确") {
|
||||
return
|
||||
@@ -66,6 +71,9 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
@@ -77,6 +85,57 @@ func CreateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func TransitionOpsDeviceActivationHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TargetState string `json:"target_state"`
|
||||
Reason string `json:"reason"`
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "激活状态迁移参数不正确") {
|
||||
return
|
||||
}
|
||||
item, err := service.TransitionActivation(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), opsdevice.ActivationTransitionInput{
|
||||
TargetState: body.TargetState, Reason: body.Reason, ExpectedRevision: body.ExpectedRevision,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsDeviceActivationTransitionsHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "设备编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
items, err := service.ListActivationTransitions(r.Context(), principal, tenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
@@ -93,13 +152,17 @@ func UpdateOpsDeviceHandler(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"`
|
||||
Status *string `json:"status"`
|
||||
HouseholdID *string `json:"household_id"`
|
||||
SubjectID *string `json:"subject_id"`
|
||||
Model *string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
|
||||
Status *string `json:"status"`
|
||||
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"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "设备修改参数不正确") {
|
||||
return
|
||||
@@ -111,6 +174,8 @@ func UpdateOpsDeviceHandler(service *opsdevice.Service) http.Handler {
|
||||
item, err := service.Update(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected, opsdevice.UpdateInput{
|
||||
Model: body.Model, HardwareRevision: body.HardwareRevision, MinimumSecurityVersion: body.MinimumSecurityVersion,
|
||||
MinimumFirmwareVersion: body.MinimumFirmwareVersion, Status: body.Status, HouseholdID: body.HouseholdID, SubjectID: body.SubjectID,
|
||||
VoiceRuntime: body.VoiceRuntime, HardwareAgentBotID: body.HardwareAgentBotID, HardwareAgentTransport: body.HardwareAgentTransport,
|
||||
HardwareAgentMinimumSDKVersion: body.HardwareAgentMinimumSDKVersion,
|
||||
})
|
||||
if err != nil {
|
||||
writeOpsDeviceError(w, r, err)
|
||||
@@ -213,6 +278,10 @@ func writeOpsDeviceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "设备信息已变化,请刷新后重试")
|
||||
case errors.Is(err, opsdevice.ErrClaimUnavailable):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_CLAIM_UNAVAILABLE", "只有已登记且尚未绑定的设备可以生成绑定码")
|
||||
case errors.Is(err, opsdevice.ErrActivationConflict):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_CONFLICT", "设备激活状态已变化或不允许执行该迁移")
|
||||
case errors.Is(err, opsdevice.ErrActivationNotReady):
|
||||
WriteError(w, r, http.StatusConflict, "DEVICE_ACTIVATION_NOT_READY", "设备绑定或运行凭据尚未就绪,不能进入 ACTIVE")
|
||||
default:
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "设备管理操作失败")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/saas"
|
||||
)
|
||||
|
||||
func ListOpsFirmwareSigningKeysHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareSigningKeys(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterOpsFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key_base64"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件签名密钥参数不正确") {
|
||||
return
|
||||
}
|
||||
input.KeyID, input.PublicKey = strings.TrimSpace(input.KeyID), strings.TrimSpace(input.PublicKey)
|
||||
decoded, decodeErr := base64.StdEncoding.DecodeString(input.PublicKey)
|
||||
if input.KeyID == "" || len([]rune(input.KeyID)) > 120 || decodeErr != nil || len(decoded) != 32 || base64.StdEncoding.EncodeToString(decoded) != input.PublicKey {
|
||||
writeValidation(w, r, "固件签名密钥参数不正确", FieldError{Field: "key", Message: "密钥编号和标准Base64编码的32字节Ed25519公钥均为必填"})
|
||||
return
|
||||
}
|
||||
if err := service.RegisterOpsFirmwareSigningKey(r.Context(), principal, tenantID, RequestID(r.Context()), input.KeyID, input.PublicKey); err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusCreated, map[string]any{"key_id": input.KeyID, "status": "ACTIVE"})
|
||||
})
|
||||
}
|
||||
|
||||
func RevokeOpsFirmwareSigningKeyHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件签名密钥吊销参数不正确") {
|
||||
return
|
||||
}
|
||||
input.KeyID = strings.TrimSpace(input.KeyID)
|
||||
if input.KeyID == "" || len([]rune(input.KeyID)) > 120 {
|
||||
writeValidation(w, r, "固件签名密钥吊销参数不正确", FieldError{Field: "key_id", Message: "必须为1至120个字符"})
|
||||
return
|
||||
}
|
||||
item, err := service.RevokeOpsFirmwareSigningKey(r.Context(), principal, tenantID, RequestID(r.Context()), input.KeyID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsFirmwareReleasesHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "UPLOADING" && status != "READY" && status != "PAUSED" && status != "RETIRED" {
|
||||
writeValidation(w, r, "固件查询参数不正确", FieldError{Field: "status", Message: "必须为UPLOADING、READY、PAUSED或RETIRED"})
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareReleases(r.Context(), principal, tenantID, strings.TrimSpace(r.URL.Query().Get("model")), status)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func PrepareOpsFirmwareReleaseHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Model string `json:"model"`
|
||||
HardwareRevision string `json:"hardware_revision"`
|
||||
Version string `json:"version"`
|
||||
ExpectedSize int64 `json:"expected_size"`
|
||||
ExpectedDigest string `json:"expected_digest"`
|
||||
Signature string `json:"signature"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
MinimumSecurityVersion string `json:"minimum_security_version"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
ReleaseChannel string `json:"release_channel"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "固件版本参数不正确") {
|
||||
return
|
||||
}
|
||||
input.Model, input.HardwareRevision, input.Version = strings.TrimSpace(input.Model), strings.TrimSpace(input.HardwareRevision), strings.TrimSpace(input.Version)
|
||||
input.ExpectedDigest, input.Signature, input.SigningKeyID = strings.ToLower(strings.TrimSpace(input.ExpectedDigest)), strings.TrimSpace(input.Signature), strings.TrimSpace(input.SigningKeyID)
|
||||
input.ReleaseChannel = strings.ToUpper(strings.TrimSpace(input.ReleaseChannel))
|
||||
if input.ReleaseChannel == "" {
|
||||
input.ReleaseChannel = "STABLE"
|
||||
}
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 || len([]rune(input.HardwareRevision)) > 80 || input.Version == "" || len([]rune(input.Version)) > 80 || input.ExpectedSize < 1 || input.ExpectedSize > 268435456 || !sha256Pattern.MatchString(input.ExpectedDigest) || input.Signature == "" || input.SigningKeyID == "" || (input.ReleaseChannel != "STABLE" && input.ReleaseChannel != "BETA" && input.ReleaseChannel != "INTERNAL") || len([]rune(input.ReleaseNotes)) > 2000 {
|
||||
writeValidation(w, r, "固件版本参数不正确", FieldError{Field: "firmware", Message: "型号、版本、1至256MB大小、SHA-256、签名、密钥编号和合法发布通道均为必填"})
|
||||
return
|
||||
}
|
||||
item, err := service.PrepareOpsFirmwareRelease(r.Context(), principal, tenantID, RequestID(r.Context()), saas.PrepareFirmwareInput{Model: input.Model, HardwareRevision: input.HardwareRevision, Version: input.Version, ExpectedSize: input.ExpectedSize, ExpectedDigest: input.ExpectedDigest, Signature: input.Signature, SigningKeyID: input.SigningKeyID, MinimumSecurityVersion: strings.TrimSpace(input.MinimumSecurityVersion), ReleaseNotes: strings.TrimSpace(input.ReleaseNotes), ReleaseChannel: input.ReleaseChannel, Mandatory: input.Mandatory})
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func OpsFirmwareReleaseActionHandler(service *saas.Service, action string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "固件版本编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
var item saas.FirmwareRelease
|
||||
var err error
|
||||
switch action {
|
||||
case "finalize":
|
||||
item, err = service.FinalizeOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "upload-session":
|
||||
item, err = service.RenewOpsFirmwareUpload(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
case "pause":
|
||||
item, err = service.PauseOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "resume":
|
||||
item, err = service.ResumeOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
case "retire":
|
||||
item, err = service.RetireOpsFirmwareRelease(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
default:
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "请求的操作不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func DownloadOpsFirmwareReleaseHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "固件版本编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.OpsFirmwareDownload(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ListOpsFirmwareRolloutsHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.ListOpsFirmwareRollouts(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func StartOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Name string `json:"name"`
|
||||
TargetPercentage int `json:"target_percentage"`
|
||||
DeviceIDs []string `json:"device_ids"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &input, "OTA升级批次参数不正确") {
|
||||
return
|
||||
}
|
||||
input.ReleaseID, input.Name = strings.TrimSpace(input.ReleaseID), strings.TrimSpace(input.Name)
|
||||
validDevices := len(input.DeviceIDs) > 0 && len(input.DeviceIDs) <= 10000
|
||||
for _, deviceID := range input.DeviceIDs {
|
||||
validDevices = validDevices && validUUID(deviceID)
|
||||
}
|
||||
if !validUUID(input.ReleaseID) || input.Name == "" || len([]rune(input.Name)) > 160 || input.TargetPercentage < 1 || input.TargetPercentage > 100 || !validDevices {
|
||||
writeValidation(w, r, "OTA升级批次参数不正确", FieldError{Field: "rollout", Message: "版本、1至160字符批次名称、1至100灰度比例和有效目标设备均为必填"})
|
||||
return
|
||||
}
|
||||
item, err := service.StartOpsFirmwareRollout(r.Context(), principal, tenantID, input.ReleaseID, RequestID(r.Context()), input.Name, input.TargetPercentage, input.DeviceIDs)
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusAccepted, item)
|
||||
})
|
||||
}
|
||||
|
||||
func GetOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "OTA升级批次编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.GetOpsFirmwareRollout(r.Context(), principal, tenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func PauseOpsFirmwareRolloutHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsFirmwarePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "OTA升级批次编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
item, err := service.PauseOpsFirmwareRollout(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeSaaSError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func opsFirmwarePrincipal(w http.ResponseWriter, r *http.Request) (identity.Principal, string, bool) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return identity.Principal{}, "", false
|
||||
}
|
||||
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
if !validUUID(tenantID) {
|
||||
writeValidation(w, r, "运维固件请求参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须提供有效的客户组织UUID"})
|
||||
return identity.Principal{}, "", false
|
||||
}
|
||||
return principal, tenantID, true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
)
|
||||
|
||||
func TestOpsFirmwareHandlersRejectInvalidBoundariesBeforeService(t *testing.T) {
|
||||
principal := identity.Principal{Issuer: "https://issuer.example", Subject: "ops-user"}
|
||||
tenantID := "10000000-0000-0000-0000-000000000010"
|
||||
tests := []struct {
|
||||
name string
|
||||
handler http.Handler
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
tenant string
|
||||
}{
|
||||
{name: "tenant required", handler: ListOpsFirmwareReleasesHandler(nil), method: http.MethodGet, path: "/api/v1/ops/firmware-releases"},
|
||||
{name: "status allowlist", handler: ListOpsFirmwareReleasesHandler(nil), method: http.MethodGet, path: "/api/v1/ops/firmware-releases?status=deleted", tenant: tenantID},
|
||||
{name: "public key exact length", handler: RegisterOpsFirmwareSigningKeyHandler(nil), method: http.MethodPost, path: "/api/v1/ops/firmware-signing-keys", body: `{"key_id":"ops-key","public_key_base64":"YQ=="}`, tenant: tenantID},
|
||||
{name: "release id uuid", handler: OpsFirmwareReleaseActionHandler(nil, "pause"), method: http.MethodPost, path: "/api/v1/ops/firmware-releases/not-a-uuid/pause", tenant: tenantID},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
request = request.WithContext(identity.WithPrincipal(request.Context(), principal))
|
||||
if test.tenant != "" {
|
||||
request.Header.Set("X-Tenant-Id", test.tenant)
|
||||
}
|
||||
if strings.Contains(test.path, "not-a-uuid") {
|
||||
request.SetPathValue("id", "not-a-uuid")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
test.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/hardwarelicense"
|
||||
)
|
||||
|
||||
func ListOpsHardwareLicensesHandler(service *hardwarelicense.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.List(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func ImportOpsHardwareLicenseHandler(service *hardwarelicense.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
LicenseRef string `json:"license_ref"`
|
||||
License string `json:"license"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProductKey string `json:"product_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
if !decodeWebJSON(w, r, &body, "License导入参数不正确") {
|
||||
return
|
||||
}
|
||||
item, err := service.Import(r.Context(), principal, tenantID, RequestID(r.Context()), hardwarelicense.ImportInput{LicenseRef: body.LicenseRef, Payload: body.License, InstanceID: body.InstanceID, ProductKey: body.ProductKey, ExpiresAt: body.ExpiresAt})
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func TransitionOpsHardwareLicenseHandler(service *hardwarelicense.Service, action string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, tenantID, ok := opsDevicePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validUUID(r.PathValue("id")) {
|
||||
writeValidation(w, r, "License编号不正确", FieldError{Field: "id", Message: "必须是UUID"})
|
||||
return
|
||||
}
|
||||
expected, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
var item hardwarelicense.Item
|
||||
if action == "release" {
|
||||
item, err = service.Release(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
|
||||
} else {
|
||||
item, err = service.Revoke(r.Context(), principal, tenantID, r.PathValue("id"), RequestID(r.Context()), expected)
|
||||
}
|
||||
if err != nil {
|
||||
writeHardwareLicenseError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func writeHardwareLicenseError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, hardwarelicense.ErrInvalid):
|
||||
writeValidation(w, r, "License参数不正确", FieldError{Field: "license", Message: "引用、密文、产品或到期时间不正确"})
|
||||
case errors.Is(err, hardwarelicense.ErrAccessDenied):
|
||||
WriteError(w, r, http.StatusForbidden, "OPS_ACCESS_DENIED", "仅独立运维账号可以管理License")
|
||||
case errors.Is(err, hardwarelicense.ErrNotFound):
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "License不存在")
|
||||
case errors.Is(err, hardwarelicense.ErrVersionConflict):
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "License信息已变化,请刷新后重试")
|
||||
case errors.Is(err, hardwarelicense.ErrAssignment):
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_STATE_CONFLICT", "License当前状态不允许执行该操作")
|
||||
default:
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
WriteError(w, r, http.StatusConflict, "HARDWARE_LICENSE_ALREADY_EXISTS", "License引用已经存在")
|
||||
return
|
||||
}
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "License管理操作失败")
|
||||
}
|
||||
}
|
||||
+86
-44
@@ -24,40 +24,45 @@ import (
|
||||
)
|
||||
|
||||
type RouterOptions struct {
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
Readiness *health.Checker
|
||||
TokenVerifier *identity.OIDCVerifier
|
||||
IdentityRepository *identity.Repository
|
||||
SaaSService *saas.Service
|
||||
DeviceVerifier DeviceRequestVerifier
|
||||
DeviceService *device.Repository
|
||||
DeviceTelemetrySubmitter DeviceTelemetrySubmitter
|
||||
MediaService *media.Service
|
||||
WebEventService *webevents.Service
|
||||
SharingService *sharing.Service
|
||||
OpsConfigService *opsconfig.Service
|
||||
BillingService *billing.Service
|
||||
AlertService *alerts.Service
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ContentModelService *contentmodel.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
ConversationHeartbeater ConversationHeartbeater
|
||||
ConversationStopper ConversationStopper
|
||||
ConversationTranscriptSubmitter ConversationTranscriptSubmitter
|
||||
ProviderCallbackSecret []byte
|
||||
ProviderCallbackSkew time.Duration
|
||||
ProviderCallbackProcessor ProviderCallbackProcessor
|
||||
PlatformHealthCallbackSecret []byte
|
||||
PlatformHealthCallbackSkew time.Duration
|
||||
PlatformHealthProcessor PlatformHealthProcessor
|
||||
MemoryToolService *memorytool.Service
|
||||
MemoryToolSigner *memorytool.TokenSigner
|
||||
ServiceName string
|
||||
Logger *slog.Logger
|
||||
RateLimiter RateLimiter
|
||||
Readiness *health.Checker
|
||||
TokenVerifier *identity.OIDCVerifier
|
||||
IdentityRepository *identity.Repository
|
||||
SaaSService *saas.Service
|
||||
DeviceVerifier DeviceRequestVerifier
|
||||
DeviceService *device.Repository
|
||||
DeviceTelemetrySubmitter DeviceTelemetrySubmitter
|
||||
MediaService *media.Service
|
||||
WebEventService *webevents.Service
|
||||
SharingService *sharing.Service
|
||||
OpsConfigService *opsconfig.Service
|
||||
BillingService *billing.Service
|
||||
AlertService *alerts.Service
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ContentModelService *contentmodel.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
ConversationHeartbeater ConversationHeartbeater
|
||||
ConversationStopper ConversationStopper
|
||||
ConversationTranscriptSubmitter ConversationTranscriptSubmitter
|
||||
HardwareAgentSessionService HardwareAgentSessionService
|
||||
HardwareAgentProvisioningService HardwareAgentProvisioningService
|
||||
DeviceFactoryResetService DeviceFactoryResetService
|
||||
ProviderCallbackSecret []byte
|
||||
ProviderCallbackSkew time.Duration
|
||||
ProviderCallbackProcessor ProviderCallbackProcessor
|
||||
NativeSubtitleSecret []byte
|
||||
NativeSubtitleProcessor NativeSubtitleProcessor
|
||||
PlatformHealthCallbackSecret []byte
|
||||
PlatformHealthCallbackSkew time.Duration
|
||||
PlatformHealthProcessor PlatformHealthProcessor
|
||||
MemoryToolService *memorytool.Service
|
||||
MemoryToolSigner *memorytool.TokenSigner
|
||||
}
|
||||
|
||||
func NewRouter(options RouterOptions) http.Handler {
|
||||
@@ -165,6 +170,8 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("DELETE /api/v1/ops/devices/{id}", authenticated(DeleteOpsDeviceHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/activation-transitions", authenticated(TransitionOpsDeviceActivationHandler(options.OpsDeviceService)))
|
||||
mux.Handle("GET /api/v1/ops/devices/{id}/activation-transitions", authenticated(ListOpsDeviceActivationTransitionsHandler(options.OpsDeviceService)))
|
||||
}
|
||||
if options.ContentModelService != nil {
|
||||
mux.Handle("GET /api/v1/content-models", authenticated(ListContentModelsHandler(options.ContentModelService)))
|
||||
@@ -223,6 +230,21 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("GET /api/v1/firmware-rollouts/{id}", authenticated(GetFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/firmware-rollouts/{id}/pause", authenticated(PauseFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/firmware-rollouts/{id}/rollback", authenticated(RollbackFirmwareRolloutHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-signing-keys", authenticated(ListOpsFirmwareSigningKeysHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-signing-keys", authenticated(RegisterOpsFirmwareSigningKeyHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-signing-keys/revoke", authenticated(RevokeOpsFirmwareSigningKeyHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-releases", authenticated(ListOpsFirmwareReleasesHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases", authenticated(PrepareOpsFirmwareReleaseHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/upload-session", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "upload-session")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/finalize", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "finalize")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/pause", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "pause")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/resume", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "resume")))
|
||||
mux.Handle("POST /api/v1/ops/firmware-releases/{id}/retire", authenticated(OpsFirmwareReleaseActionHandler(options.SaaSService, "retire")))
|
||||
mux.Handle("GET /api/v1/ops/firmware-releases/{id}/download", authenticated(DownloadOpsFirmwareReleaseHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/ops/firmware-rollouts", authenticated(ListOpsFirmwareRolloutsHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/ops/firmware-rollouts", authenticated(StartOpsFirmwareRolloutHandler(options.SaaSService)))
|
||||
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/conversations/{id}", authenticated(GetConversationHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/usage", authenticated(GetConversationUsageHandler(options.SaaSService)))
|
||||
@@ -277,6 +299,12 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
deviceAuthenticated := func(handler http.Handler) http.Handler {
|
||||
return DeviceAuth(options.DeviceVerifier, DeviceRateLimit(logger, options.RateLimiter, handler))
|
||||
}
|
||||
activeDevice := func(handler http.Handler) http.Handler {
|
||||
return deviceAuthenticated(RequireDeviceActivation([]string{"ACTIVE"}, handler))
|
||||
}
|
||||
provisionableDevice := func(handler http.Handler) http.Handler {
|
||||
return deviceAuthenticated(RequireDeviceActivation([]string{"BOUND", "PROVISIONING", "ACTIVE"}, handler))
|
||||
}
|
||||
mux.Handle("GET /device/v2/bootstrap", deviceAuthenticated(DeviceBootstrapHandler(options.DeviceService)))
|
||||
mux.Handle("GET /device/v2/config", deviceAuthenticated(DeviceConfigHandler(options.DeviceService)))
|
||||
mux.Handle("GET /device/v2/commands", deviceAuthenticated(PullDeviceCommandsHandler(options.DeviceService)))
|
||||
@@ -285,32 +313,46 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /device/v2/telemetry:batch", deviceAuthenticated(SubmitDeviceTelemetryHandler(options.DeviceTelemetrySubmitter)))
|
||||
}
|
||||
mux.Handle("GET /device/v2/firmware-releases/{id}/download", deviceAuthenticated(DeviceFirmwareDownloadHandler(options.SaaSService)))
|
||||
if options.DeviceFactoryResetService != nil {
|
||||
mux.Handle("POST /device/v3/factory-reset", deviceAuthenticated(DeviceFactoryResetHandler(options.DeviceFactoryResetService)))
|
||||
}
|
||||
if options.MediaService != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/upload-batches", deviceAuthenticated(PrepareMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/upload-batches/{id}/complete", deviceAuthenticated(CompleteMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("GET /device/v2/upload-batches/{id}", deviceAuthenticated(GetDeviceMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/media-segments/{id}/local-delete-ack", deviceAuthenticated(AcknowledgeLocalMediaDeleteHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/upload-batches", activeDevice(PrepareMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/upload-batches/{id}/complete", activeDevice(CompleteMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("GET /device/v2/upload-batches/{id}", activeDevice(GetDeviceMediaUploadBatchHandler(options.MediaService)))
|
||||
mux.Handle("POST /device/v2/media-segments/{id}/local-delete-ack", activeDevice(AcknowledgeLocalMediaDeleteHandler(options.MediaService)))
|
||||
}
|
||||
if options.ConversationCreator != nil {
|
||||
mux.Handle("POST /device/v2/conversations", deviceAuthenticated(CreateConversationHandler(options.ConversationCreator)))
|
||||
mux.Handle("POST /device/v2/conversations", activeDevice(CreateConversationHandler(options.ConversationCreator)))
|
||||
}
|
||||
if options.ConversationActivator != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/activate", deviceAuthenticated(ActivateConversationHandler(options.ConversationActivator)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/activate", activeDevice(ActivateConversationHandler(options.ConversationActivator)))
|
||||
}
|
||||
if options.DeviceConversationStateReader != nil {
|
||||
mux.Handle("GET /device/v2/conversations/{id}", deviceAuthenticated(GetDeviceConversationStateHandler(options.DeviceConversationStateReader)))
|
||||
mux.Handle("GET /device/v2/conversations/{id}", activeDevice(GetDeviceConversationStateHandler(options.DeviceConversationStateReader)))
|
||||
}
|
||||
if options.ConversationHeartbeater != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/heartbeat", deviceAuthenticated(HeartbeatConversationHandler(options.ConversationHeartbeater)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/heartbeat", activeDevice(HeartbeatConversationHandler(options.ConversationHeartbeater)))
|
||||
}
|
||||
if options.ConversationStopper != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/stop", deviceAuthenticated(StopConversationHandler(options.ConversationStopper)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/stop", activeDevice(StopConversationHandler(options.ConversationStopper)))
|
||||
}
|
||||
if options.ConversationTranscriptSubmitter != nil {
|
||||
mux.Handle("POST /device/v2/conversations/{id}/turns:batch", deviceAuthenticated(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
mux.Handle("POST /device/v2/conversations/{id}/turns:batch", activeDevice(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
}
|
||||
if options.HardwareAgentSessionService != nil {
|
||||
if options.HardwareAgentProvisioningService != nil {
|
||||
mux.Handle("POST /device/v3/hardware-agent-credentials:provision", provisionableDevice(ProvisionHardwareAgentCredentialHandler(options.HardwareAgentProvisioningService)))
|
||||
}
|
||||
mux.Handle("POST /device/v3/agent-sessions", activeDevice(CreateHardwareAgentSessionHandler(options.HardwareAgentSessionService)))
|
||||
mux.Handle("POST /device/v3/agent-sessions/{id}/events", activeDevice(HardwareAgentEventHandler(options.HardwareAgentSessionService)))
|
||||
mux.Handle("POST /device/v3/agent-sessions/{id}/turns:batch", activeDevice(SubmitConversationTurnsHandler(options.ConversationTranscriptSubmitter)))
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
mux.Handle("POST /callbacks/v1/vertc/voice-chat", VoiceProviderCallbackHandler(
|
||||
options.ProviderCallbackSecret, options.ProviderCallbackSkew, options.ProviderCallbackProcessor,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type NativeSubtitleProcessor interface {
|
||||
ProcessNativeSubtitle(context.Context, string, volcsubtitle.Packet) (orchestration.NativeSubtitleResult, error)
|
||||
}
|
||||
|
||||
type NativeSubtitleDeviceProbe interface {
|
||||
ProbeNativeSubtitleDevice(context.Context, volcsubtitle.Packet) error
|
||||
}
|
||||
|
||||
func NativeSubtitleCallbackHandler(secret []byte, processor NativeSubtitleProcessor) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("conversation_id")
|
||||
var envelope volcsubtitle.Envelope
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 384*1024))
|
||||
if err != nil || json.Unmarshal(raw, &envelope) != nil {
|
||||
WriteError(w, r, 400, "INVALID_SUBTITLE", "字幕格式不正确")
|
||||
return
|
||||
}
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
if len(secret) < 32 || subtle.ConstantTimeCompare([]byte(envelope.Signature), secret) != 1 {
|
||||
WriteError(w, r, 401, "SUBTITLE_AUTHENTICATION_FAILED", "Subtitle authentication failed")
|
||||
return
|
||||
}
|
||||
packet, err := volcsubtitle.Decode(envelope.Message)
|
||||
if err != nil || packet.DeviceName == "" || packet.ProductKey == "" {
|
||||
WriteError(w, r, 400, "SUBTITLE_SESSION_REQUIRED", "Subtitle session identity missing")
|
||||
return
|
||||
}
|
||||
probe, ok := processor.(NativeSubtitleDeviceProbe)
|
||||
if !ok {
|
||||
WriteError(w, r, 503, "SUBTITLE_UNAVAILABLE", "Subtitle probe unavailable")
|
||||
return
|
||||
}
|
||||
if err = probe.ProbeNativeSubtitleDevice(r.Context(), packet); err != nil {
|
||||
status := 503
|
||||
if errors.Is(err, orchestration.ErrConversationNotFound) {
|
||||
status = 404
|
||||
}
|
||||
WriteError(w, r, status, "SUBTITLE_DEVICE_UNAVAILABLE", "Subtitle device unavailable")
|
||||
return
|
||||
}
|
||||
// Never associate a callback with the newest device session: delayed
|
||||
// callbacks from before reboot cannot be distinguished in this format.
|
||||
slog.InfoContext(r.Context(), "native hardware subtitle verified", "mode", "device_probe", "device_name", packet.DeviceName, "segments", len(packet.Data), "stored", false)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
expected := volcsubtitle.Signature(secret, id)
|
||||
if len(secret) < 32 || subtle.ConstantTimeCompare([]byte(envelope.Signature), []byte(expected)) != 1 {
|
||||
WriteError(w, r, 401, "SUBTITLE_AUTHENTICATION_FAILED", "字幕回调鉴权失败")
|
||||
return
|
||||
}
|
||||
packet, err := volcsubtitle.Decode(envelope.Message)
|
||||
if err != nil {
|
||||
WriteError(w, r, 400, "INVALID_SUBTITLE", "字幕格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := processor.ProcessNativeSubtitle(r.Context(), id, packet)
|
||||
if err != nil {
|
||||
status, code := 503, "SUBTITLE_UNAVAILABLE"
|
||||
if errors.Is(err, orchestration.ErrConversationNotFound) {
|
||||
status, code = 404, "SUBTITLE_SESSION_NOT_FOUND"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrTranscriptStorageNotAllowed) {
|
||||
status, code = 403, "TRANSCRIPT_STORAGE_NOT_ALLOWED"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrNativeSubtitleSpeaker) {
|
||||
status, code = 422, "SUBTITLE_SPEAKER_UNKNOWN"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrHardwareEventConflict) {
|
||||
status, code = 409, "SUBTITLE_CONFLICT"
|
||||
}
|
||||
if errors.Is(err, orchestration.ErrConversationStateConflict) {
|
||||
status, code = 409, "SUBTITLE_SESSION_STATE"
|
||||
}
|
||||
slog.WarnContext(r.Context(), "native subtitle rejected", "conversation_id", id, "code", code)
|
||||
WriteError(w, r, status, code, "字幕回调暂未接收")
|
||||
return
|
||||
}
|
||||
slog.InfoContext(r.Context(), "native subtitle accepted", "conversation_id", id, "mode", result.Mode, "segments", len(packet.Data), "accepted", result.Accepted, "duplicates", result.Duplicates)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/orchestration"
|
||||
"data-terminal-cloud/internal/volcsubtitle"
|
||||
)
|
||||
|
||||
type subtitleTestProcessor struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *subtitleTestProcessor) ProbeNativeSubtitleDevice(_ context.Context, _ volcsubtitle.Packet) error {
|
||||
p.calls++
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *subtitleTestProcessor) ProcessNativeSubtitle(_ context.Context, _ string, _ volcsubtitle.Packet) (orchestration.NativeSubtitleResult, error) {
|
||||
p.calls++
|
||||
return orchestration.NativeSubtitleResult{Mode: "probe"}, p.err
|
||||
}
|
||||
|
||||
func TestNativeSubtitleHTTPAuthenticationAndFraming(t *testing.T) {
|
||||
secret := []byte("test-native-subtitle-secret-32-bytes")
|
||||
id := "de4e5d8a-708e-4e9b-b565-9265a9b6873a"
|
||||
payload := []byte(`{"type":"subtitle","data":[{"userId":"test-device","roundId":0,"sequence":0,"definite":true,"text":"测试"}]}`)
|
||||
frame := append(make([]byte, 8), payload...)
|
||||
copy(frame, "subv")
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||
for _, tc := range []struct {
|
||||
name, id, sig, message string
|
||||
status int
|
||||
calls int
|
||||
}{
|
||||
{"no Content-Type", id, volcsubtitle.Signature(secret, id), base64.StdEncoding.EncodeToString(frame), 200, 1},
|
||||
{"another conversation", id, volcsubtitle.Signature(secret, "other"), base64.StdEncoding.EncodeToString(frame), 401, 0},
|
||||
{"missing conversation", "", string(secret), base64.StdEncoding.EncodeToString(frame), 400, 0},
|
||||
{"invalid frame", id, volcsubtitle.Signature(secret, id), "bad", 400, 0},
|
||||
{"hardware device probe", "", string(secret), base64.StdEncoding.EncodeToString([]byte(`{"type":"subtitle","productKey":"product","deviceName":"device","data":[{"userId":"input","roundId":1,"sequence":0,"definite":true,"text":"test"}]}`)), 200, 1},
|
||||
{"hardware invalid signature", "", "wrong", base64.StdEncoding.EncodeToString([]byte(`{"type":"subtitle","productKey":"product","deviceName":"device","data":[{"userId":"input","roundId":1,"sequence":0,"definite":true,"text":"test"}]}`)), 401, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &subtitleTestProcessor{}
|
||||
body, _ := json.Marshal(volcsubtitle.Envelope{Message: tc.message, Signature: tc.sig})
|
||||
req := httptest.NewRequest("POST", "/callbacks/v1/volcengine/subtitles?conversation_id="+tc.id, strings.NewReader(string(body)))
|
||||
w := httptest.NewRecorder()
|
||||
NativeSubtitleCallbackHandler(secret, p).ServeHTTP(w, req)
|
||||
if w.Code != tc.status || p.calls != tc.calls {
|
||||
t.Fatalf("status=%d calls=%d body=%s", w.Code, p.calls, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user