chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
@@ -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"},
+38
View File
@@ -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)
})
}
+47
View File
@@ -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())
}
}
+216
View File
@@ -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())
}
}
+82 -13
View File
@@ -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", "设备管理操作失败")
}
+295
View File
@@ -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
View File
@@ -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,
))
+102
View File
@@ -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())
}
})
}
}